五项家庭化改造:以个人为中心 · 家谱 · 三种角色 · 附件存储可配置 · 独立页样式
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:
@@ -9,6 +9,8 @@ namespace App;
|
|||||||
|
|
||||||
final class Account
|
final class Account
|
||||||
{
|
{
|
||||||
|
public const ROLES = ['admin', 'member', 'guest'];
|
||||||
|
|
||||||
public static function findByPerson(int $personId): ?array
|
public static function findByPerson(int $personId): ?array
|
||||||
{
|
{
|
||||||
$st = Database::get()->prepare('SELECT * FROM accounts WHERE person_id = ?');
|
$st = Database::get()->prepare('SELECT * FROM accounts WHERE person_id = ?');
|
||||||
@@ -19,6 +21,7 @@ final class Account
|
|||||||
|
|
||||||
public static function create(int $personId, string $password, string $role = 'member'): int
|
public static function create(int $personId, string $password, string $role = 'member'): int
|
||||||
{
|
{
|
||||||
|
$role = in_array($role, self::ROLES, true) ? $role : 'member';
|
||||||
$st = Database::get()->prepare(
|
$st = Database::get()->prepare(
|
||||||
'INSERT INTO accounts (person_id, password_hash, role) VALUES (?, ?, ?)'
|
'INSERT INTO accounts (person_id, password_hash, role) VALUES (?, ?, ?)'
|
||||||
);
|
);
|
||||||
@@ -34,6 +37,7 @@ final class Account
|
|||||||
|
|
||||||
public static function setRole(int $accountId, string $role): void
|
public static function setRole(int $accountId, string $role): void
|
||||||
{
|
{
|
||||||
|
$role = in_array($role, self::ROLES, true) ? $role : 'member';
|
||||||
Database::get()->prepare('UPDATE accounts SET role = ? WHERE id = ?')
|
Database::get()->prepare('UPDATE accounts SET role = ? WHERE id = ?')
|
||||||
->execute([$role, $accountId]);
|
->execute([$role, $accountId]);
|
||||||
}
|
}
|
||||||
@@ -44,6 +48,24 @@ final class Account
|
|||||||
->execute([$status, $accountId]);
|
->execute([$status, $accountId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 各角色各有多少人(设置页显示) */
|
||||||
|
public static function roleCounts(): array
|
||||||
|
{
|
||||||
|
$st = Database::get()->query(
|
||||||
|
"SELECT role, COUNT(*) AS n FROM accounts WHERE status = 'active' GROUP BY role"
|
||||||
|
);
|
||||||
|
$out = ['admin' => 0, 'member' => 0, 'guest' => 0];
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
$out[(string)$r['role']] = (int)$r['n'];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function roleWord(string $role): string
|
||||||
|
{
|
||||||
|
return ['admin' => '管理员', 'member' => '家庭成员', 'guest' => '访客'][$role] ?? '成员';
|
||||||
|
}
|
||||||
|
|
||||||
/** 管理员不能被停用/降权 —— 否则全家人都会被锁在系统外面 */
|
/** 管理员不能被停用/降权 —— 否则全家人都会被锁在系统外面 */
|
||||||
public static function adminCount(): int
|
public static function adminCount(): int
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -43,6 +43,39 @@ final class Auth
|
|||||||
return ($_SESSION['role'] ?? '') === 'admin';
|
return ($_SESSION['role'] ?? '') === 'admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 访客:只能看被授权的信息,不能创建/编辑任何东西 */
|
||||||
|
public static function isGuest(): bool
|
||||||
|
{
|
||||||
|
return ($_SESSION['role'] ?? '') === 'guest';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 能正常使用系统的人(管理员或家庭成员)—— 判断「能否写」用这个 */
|
||||||
|
public static function canWrite(): bool
|
||||||
|
{
|
||||||
|
$r = $_SESSION['role'] ?? '';
|
||||||
|
return $r === 'admin' || $r === 'member';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function role(): string
|
||||||
|
{
|
||||||
|
return (string)($_SESSION['role'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function roleWord(): string
|
||||||
|
{
|
||||||
|
return ['admin' => '管理员', 'member' => '家庭成员', 'guest' => '访客'][self::role()] ?? '成员';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页标题 —— 「以我为中心」最直接的一处落点。
|
||||||
|
* 登录后看到的不是「家书」,而是「老杨的家书」。
|
||||||
|
*/
|
||||||
|
public static function homeTitle(): string
|
||||||
|
{
|
||||||
|
$name = self::name();
|
||||||
|
return $name === '' ? '家书' : $name . '的家书';
|
||||||
|
}
|
||||||
|
|
||||||
/** 未登录跳登录页 */
|
/** 未登录跳登录页 */
|
||||||
public static function requireLogin(): void
|
public static function requireLogin(): void
|
||||||
{
|
{
|
||||||
@@ -51,6 +84,20 @@ final class Auth
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 需要「能写」的权限(访客会被挡下) */
|
||||||
|
public static function requireWrite(): void
|
||||||
|
{
|
||||||
|
self::requireLogin();
|
||||||
|
if (!self::canWrite()) {
|
||||||
|
http_response_code(403);
|
||||||
|
\App\View::render('error', [
|
||||||
|
'title' => '访客只读',
|
||||||
|
'message' => '你的身份是访客,只能查看,不能新增或修改内容。',
|
||||||
|
], 'home');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static function requireAdmin(): void
|
public static function requireAdmin(): void
|
||||||
{
|
{
|
||||||
self::requireLogin();
|
self::requireLogin();
|
||||||
|
|||||||
+486
@@ -0,0 +1,486 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 亲属关系:**只存相邻两代之间的边,其余全部推导**。
|
||||||
|
*
|
||||||
|
* 设计(这是整个家谱功能的地基,改动前请先读懂):
|
||||||
|
* - 库里只有一张 relations 表,两种边:
|
||||||
|
* parent : from 是 to 的父或母(from_role 记 father/mother)
|
||||||
|
* spouse : from 与 to 是配偶(对称,只存一条)
|
||||||
|
* - 不做「爷爷」「岳母」这种一次性称呼的存储。要知道奶奶是谁,
|
||||||
|
* 就是「我的父亲」再「我父亲的母亲」——两级边串起来。这样:
|
||||||
|
* ① 关系只有两个方向要维护,不会出现「我记了他是我爸,他那边没记我」
|
||||||
|
* ② 同一条边在不同人眼里自动呈现不同称呼
|
||||||
|
* (我的「父亲」= 我孩子的「爷爷」= 我爱人的「公公」)
|
||||||
|
* ③ 虚拟人物(is_placeholder)可以参与关系网,等弄清真实身份再改名即可
|
||||||
|
* - 兄弟姐妹**不存边**:他们共享同一对父母,由「父母的子女」反查得出。
|
||||||
|
* 排行由 persons.birth_rank 决定,用来显示长子/次女/三弟。
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Kinship
|
||||||
|
{
|
||||||
|
/** 一次把全家的边读进内存 —— 几十人的家庭,比反复查库快得多,也让推导逻辑干净 */
|
||||||
|
public static function graph(int $familyId): array
|
||||||
|
{
|
||||||
|
$parents = []; // childId => [parentId => 'father'|'mother']
|
||||||
|
$children = []; // parentId => [childId, ...]
|
||||||
|
$spouses = []; // id => [spouseId, ...]
|
||||||
|
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT from_id, to_id, kind, from_role FROM relations WHERE family_id = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId]);
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
$f = (int)$r['from_id'];
|
||||||
|
$t = (int)$r['to_id'];
|
||||||
|
if ($r['kind'] === 'parent') {
|
||||||
|
$role = $r['from_role'] === 'mother' ? 'mother' : 'father';
|
||||||
|
$parents[$t][$f] = $role;
|
||||||
|
$children[$f][] = $t;
|
||||||
|
} else {
|
||||||
|
$spouses[$f][] = $t;
|
||||||
|
$spouses[$t][] = $f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($children as $k => $v) {
|
||||||
|
$children[$k] = array_values(array_unique($v));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['parents' => $parents, 'children' => $children, 'spouses' => $spouses];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 某人的父母(father / mother 两个键,可能缺) */
|
||||||
|
public static function parents(array $g, int $id): array
|
||||||
|
{
|
||||||
|
$out = ['father' => null, 'mother' => null];
|
||||||
|
foreach ($g['parents'][$id] ?? [] as $pid => $role) {
|
||||||
|
$out[$role] = (int)$pid;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 父母的 id 列表(不含 null),判断「跟我有关系」时用 */
|
||||||
|
public static function parentIds(array $g, int $id): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach (self::parents($g, $id) as $pid) {
|
||||||
|
if ($pid !== null) {
|
||||||
|
$out[] = $pid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function spouseIds(array $g, int $id): array
|
||||||
|
{
|
||||||
|
return $g['spouses'][$id] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兄弟姐妹:与「我」共享至少一位父母的人(不含自己) */
|
||||||
|
public static function siblingIds(array $g, int $id): array
|
||||||
|
{
|
||||||
|
$sib = [];
|
||||||
|
foreach ($g['parents'][$id] ?? [] as $pid => $_) {
|
||||||
|
foreach ($g['children'][$pid] ?? [] as $c) {
|
||||||
|
if ((int)$c !== $id) {
|
||||||
|
$sib[(int)$c] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_keys($sib);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 子女 */
|
||||||
|
public static function childIds(array $g, int $id): array
|
||||||
|
{
|
||||||
|
return array_map('intval', $g['children'][$id] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 祖父母那一级:往上走一步再取父母。
|
||||||
|
* 只认「我的父母」,因为只有相邻两代有边 —— 这正是「只存两代」的兑现处。
|
||||||
|
*/
|
||||||
|
public static function grandparents(array $g, int $id): array
|
||||||
|
{
|
||||||
|
$out = ['paternal' => ['father' => null, 'mother' => null],
|
||||||
|
'maternal' => ['father' => null, 'mother' => null]];
|
||||||
|
$p = self::parents($g, $id);
|
||||||
|
if ($p['father']) {
|
||||||
|
$out['paternal'] = self::parents($g, $p['father']);
|
||||||
|
}
|
||||||
|
if ($p['mother']) {
|
||||||
|
$out['maternal'] = self::parents($g, $p['mother']);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我该怎么称呼对方 —— 整个系统的「以我为中心」就落在这个函数上。
|
||||||
|
*
|
||||||
|
* 推导顺序:自己 → 配偶 → 父母/子女 → 兄弟姐妹 → 父母的兄弟姐妹 →
|
||||||
|
* 配偶的父母 → 祖父母/孙辈 → 姻亲 → 兜底「亲属」
|
||||||
|
*/
|
||||||
|
public static function label(array $g, int $me, int $other, array $people): string
|
||||||
|
{
|
||||||
|
if ($me === $other) {
|
||||||
|
return '我';
|
||||||
|
}
|
||||||
|
|
||||||
|
$gender = strtolower((string)($people[$other]['gender'] ?? 'x'));
|
||||||
|
$isMale = $gender === 'm';
|
||||||
|
$isKnown = $gender === 'm' || $gender === 'f';
|
||||||
|
|
||||||
|
// ---- 配偶 ----
|
||||||
|
if (in_array($other, self::spouseIds($g, $me), true)) {
|
||||||
|
return self::spouseLabel($g, $me, $isMale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 直系:父母 / 子女 ----
|
||||||
|
$myParents = self::parents($g, $me);
|
||||||
|
foreach (['father', 'mother'] as $role) {
|
||||||
|
if ($myParents[$role] === $other) {
|
||||||
|
return $role === 'father' ? '父亲' : '母亲';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (in_array($other, self::childIds($g, $me), true)) {
|
||||||
|
return self::rankLabel($g, $people, $me, $other, $isMale, $isKnown);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 兄弟姐妹(含排行)----
|
||||||
|
foreach (self::siblingIds($g, $me) as $sib) {
|
||||||
|
if ($sib === $other) {
|
||||||
|
// compareRank(我, 他) < 0 表示他的排行键更小 —— 也就是他比我年长。
|
||||||
|
// 方向很容易写反:这里比的是「他 vs 我」,不是「我 vs 他」。
|
||||||
|
$heIsOlder = self::compareRank($people, $other, $me) < 0;
|
||||||
|
if ($isKnown) {
|
||||||
|
if ($isMale) {
|
||||||
|
return $heIsOlder ? '哥哥' : '弟弟';
|
||||||
|
}
|
||||||
|
return $heIsOlder ? '姐姐' : '妹妹';
|
||||||
|
}
|
||||||
|
return $heIsOlder ? '哥哥' : '弟弟';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 父母的兄弟姐妹:伯/叔/姑/舅/姨 ----
|
||||||
|
$uncle = self::uncleLabel($g, $people, $me, $other);
|
||||||
|
if ($uncle !== null) {
|
||||||
|
return $uncle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 配偶的父母:公公婆婆 / 岳父岳母 ----
|
||||||
|
foreach (self::spouseIds($g, $me) as $sp) {
|
||||||
|
$spParents = self::parents($g, $sp);
|
||||||
|
foreach (['father', 'mother'] as $role) {
|
||||||
|
if ($spParents[$role] === $other) {
|
||||||
|
$iAmMale = strtolower((string)($people[$me]['gender'] ?? 'x')) === 'm';
|
||||||
|
if ($role === 'father') {
|
||||||
|
return $iAmMale ? '岳父' : '公公';
|
||||||
|
}
|
||||||
|
return $iAmMale ? '岳母' : '婆婆';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 祖父母 / 外祖父母 ----
|
||||||
|
$gp = self::grandparents($g, $me);
|
||||||
|
foreach (['paternal' => '爷爷', 'maternal' => '外公'] as $side => $maleWord) {
|
||||||
|
$femaleWord = $side === 'paternal' ? '奶奶' : '外婆';
|
||||||
|
if (($gp[$side]['father'] ?? null) === $other) {
|
||||||
|
return $maleWord;
|
||||||
|
}
|
||||||
|
if (($gp[$side]['mother'] ?? null) === $other) {
|
||||||
|
return $femaleWord;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 孙辈 / 曾孙辈 ----
|
||||||
|
// 只往下走两级:孙辈(子女的子女)与曾孙辈(子女的孙辈)。
|
||||||
|
// 再往下就不必细分了,统一「后代」。
|
||||||
|
foreach (self::childIds($g, $me) as $child) {
|
||||||
|
foreach (self::childIds($g, $child) as $gc) {
|
||||||
|
if ($gc === $other) {
|
||||||
|
if (!$isKnown) {
|
||||||
|
return '孙辈';
|
||||||
|
}
|
||||||
|
return $isMale ? '孙子' : '孙女';
|
||||||
|
}
|
||||||
|
if (in_array($other, self::childIds($g, $gc), true)) {
|
||||||
|
if (!$isKnown) {
|
||||||
|
return '后代';
|
||||||
|
}
|
||||||
|
return $isMale ? '曾孙' : '曾孙女';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 姻亲:配偶的兄弟姐妹 / 兄弟姐妹的配偶 ----
|
||||||
|
foreach (self::spouseIds($g, $me) as $sp) {
|
||||||
|
if (in_array($other, self::siblingIds($g, $sp), true)) {
|
||||||
|
return $isKnown ? ($isMale ? '大舅子/小舅子' : '大姨子/小姨子') : '姻亲';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (self::siblingIds($g, $me) as $sib) {
|
||||||
|
if (in_array($other, self::spouseIds($g, $sib), true)) {
|
||||||
|
return $isKnown ? ($isMale ? '姐夫/妹夫' : '嫂子/弟媳') : '姻亲';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 表亲堂亲:父母的兄弟姐妹的子女 ----
|
||||||
|
foreach (self::parentSiblings($g, $me) as $ps) {
|
||||||
|
if (in_array($other, self::childIds($g, $ps), true)) {
|
||||||
|
return '表亲';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '亲属';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 配偶的称呼:按「我的性别」定,因为中文里丈夫/妻子是从自己视角说的 */
|
||||||
|
private static function spouseLabel(array $g, int $me, bool $otherIsMale): string
|
||||||
|
{
|
||||||
|
if ($otherIsMale) {
|
||||||
|
return '丈夫';
|
||||||
|
}
|
||||||
|
return '妻子';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子女的排行称呼:长子/次子/三子…,长女/次女/三女…
|
||||||
|
* 优先用 persons.birth_rank;没填就按生日或 id 推。
|
||||||
|
*
|
||||||
|
* ⚠️ $isMale 必须由调用方按 gender 传入 —— 早期这里写死成「子」,
|
||||||
|
* 结果女儿一律显示成「次子」,是很容易漏的一类 bug。
|
||||||
|
* 性别未知时退化成中性的「孩子」,不要瞎猜成儿子。
|
||||||
|
*/
|
||||||
|
private static function rankLabel(array $g, array $people, int $parent, int $child, bool $isMale, bool $known): string
|
||||||
|
{
|
||||||
|
$base = $known ? ($isMale ? '子' : '女') : '孩子';
|
||||||
|
$sibs = self::childIds($g, $parent);
|
||||||
|
if (count($sibs) <= 1) {
|
||||||
|
return $known ? ($isMale ? '儿子' : '女儿') : '孩子';
|
||||||
|
}
|
||||||
|
$rank = self::rankOf($people, $sibs, $child);
|
||||||
|
$prefix = ['', '长', '次', '三', '四', '五', '六', '七', '八', '九'][$rank] ?? ($rank . '');
|
||||||
|
return $prefix === '' ? $base : $prefix . $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 我在兄弟姐妹里排第几(1 起) */
|
||||||
|
private static function rankOf(array $people, array $sibs, int $id): int
|
||||||
|
{
|
||||||
|
$sorted = $sibs;
|
||||||
|
usort($sorted, function ($a, $b) use ($people) {
|
||||||
|
$ra = self::rankValue($people, $a);
|
||||||
|
$rb = self::rankValue($people, $b);
|
||||||
|
return $ra <=> $rb;
|
||||||
|
});
|
||||||
|
$pos = array_search($id, $sorted, true);
|
||||||
|
return $pos === false ? 1 : $pos + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排序用的键:显式 birth_rank 优先,否则用生日,再否则用 id */
|
||||||
|
private static function rankValue(array $people, int $id): string
|
||||||
|
{
|
||||||
|
$p = $people[$id] ?? [];
|
||||||
|
if (!empty($p['birth_rank'])) {
|
||||||
|
return sprintf('%06d', (int)$p['birth_rank']);
|
||||||
|
}
|
||||||
|
if (!empty($p['birthday'])) {
|
||||||
|
return '5' . (string)$p['birthday'];
|
||||||
|
}
|
||||||
|
return '9' . sprintf('%09d', $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 谁是哥哥谁是弟弟:比较排行键 */
|
||||||
|
private static function compareRank(array $people, int $a, int $b): int
|
||||||
|
{
|
||||||
|
return strcmp(self::rankValue($people, $a), self::rankValue($people, $b));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 父母的兄弟姐妹(伯父/叔叔/姑姑/舅舅/姨妈) */
|
||||||
|
private static function uncleLabel(array $g, array $people, int $me, int $other): ?string
|
||||||
|
{
|
||||||
|
$sibs = [];
|
||||||
|
$myParents = self::parents($g, $me);
|
||||||
|
$paternal = $myParents['father'] !== null;
|
||||||
|
foreach (['father' => true, 'mother' => false] as $role => $isPat) {
|
||||||
|
$pid = $myParents[$role];
|
||||||
|
if ($pid === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (self::siblingIds($g, $pid) as $sib) {
|
||||||
|
$sibs[$sib] = ['side' => $isPat ? 'paternal' : 'maternal', 'parent' => $pid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isset($sibs[$other])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$side = $sibs[$other]['side'];
|
||||||
|
$parent = $sibs[$other]['parent'];
|
||||||
|
$gender = strtolower((string)($people[$other]['gender'] ?? 'x'));
|
||||||
|
$isMale = $gender === 'm';
|
||||||
|
$older = self::compareRank($people, $other, $parent) < 0; // 比我的父母年长
|
||||||
|
|
||||||
|
if ($side === 'paternal') {
|
||||||
|
if ($isMale) {
|
||||||
|
return $older ? '伯父' : '叔叔';
|
||||||
|
}
|
||||||
|
return $gender === 'f' ? '姑姑' : '姑姑';
|
||||||
|
}
|
||||||
|
if ($isMale) {
|
||||||
|
return '舅舅';
|
||||||
|
}
|
||||||
|
return $gender === 'f' ? '姨妈' : '姨妈';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 父母的兄弟姐妹(不含配偶那边的) */
|
||||||
|
private static function parentSiblings(array $g, int $me): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach (self::parents($g, $me) as $pid) {
|
||||||
|
if ($pid === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (self::siblingIds($g, $pid) as $sib) {
|
||||||
|
$out[] = $sib;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= 家谱渲染所需的结构化数据 =================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以 $me 为中心,向上向下各展开若干代,产出可渲染的家谱树。
|
||||||
|
*
|
||||||
|
* 返回结构:
|
||||||
|
* [
|
||||||
|
* 'generations' => [ -2 => [节点...], -1 => [...], 0 => [...], 1 => [...] ],
|
||||||
|
* 'nodes' => [ id => ['id','name','label','rel'][...] ],
|
||||||
|
* ]
|
||||||
|
* 其中 label 是「我该怎么称呼他」,rel 是「他是我的谁」(用于画连接线)。
|
||||||
|
*/
|
||||||
|
public static function tree(array $g, int $me, array $people, int $up = 3, int $down = 3): array
|
||||||
|
{
|
||||||
|
$gen = []; // id => 相对我的代数(我 = 0,父母 = -1,子女 = +1)
|
||||||
|
$seen = [];
|
||||||
|
$queue = [[$me, 0]];
|
||||||
|
$seen[$me] = true;
|
||||||
|
|
||||||
|
while ($queue) {
|
||||||
|
[$id, $level] = array_shift($queue);
|
||||||
|
|
||||||
|
// 配偶永远同代
|
||||||
|
foreach (self::spouseIds($g, $id) as $sp) {
|
||||||
|
if (!isset($seen[$sp])) {
|
||||||
|
$seen[$sp] = true;
|
||||||
|
$gen[$sp] = $level;
|
||||||
|
$queue[] = [$sp, $level];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 往上
|
||||||
|
if ($level > -$up) {
|
||||||
|
foreach (self::parents($g, $id) as $pid) {
|
||||||
|
if ($pid !== null && !isset($seen[$pid])) {
|
||||||
|
$seen[$pid] = true;
|
||||||
|
$gen[$pid] = $level - 1;
|
||||||
|
$queue[] = [$pid, $level - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 往下
|
||||||
|
if ($level < $down) {
|
||||||
|
foreach (self::childIds($g, $id) as $cid) {
|
||||||
|
if (!isset($seen[$cid])) {
|
||||||
|
$seen[$cid] = true;
|
||||||
|
$gen[$cid] = $level + 1;
|
||||||
|
$queue[] = [$cid, $level + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$gen[$me] = 0;
|
||||||
|
|
||||||
|
$generations = [];
|
||||||
|
$nodes = [];
|
||||||
|
foreach ($gen as $id => $level) {
|
||||||
|
if (!isset($people[$id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$label = self::label($g, $me, $id, $people);
|
||||||
|
$nodes[$id] = [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => (string)$people[$id]['name'],
|
||||||
|
'label' => $label,
|
||||||
|
'rel' => self::relationWord($g, $me, $id),
|
||||||
|
'avatar'=> (string)($people[$id]['avatar'] ?? ''),
|
||||||
|
'gender'=> (string)($people[$id]['gender'] ?? 'x'),
|
||||||
|
'ph' => (int)($people[$id]['is_placeholder'] ?? 0) === 1,
|
||||||
|
'person'=> $people[$id],
|
||||||
|
];
|
||||||
|
$generations[$level][] = $id;
|
||||||
|
}
|
||||||
|
ksort($generations);
|
||||||
|
foreach ($generations as &$ids) {
|
||||||
|
// 同代内排序:先配偶,再按排行
|
||||||
|
usort($ids, function ($a, $b) use ($people) {
|
||||||
|
return strcmp(self::rankValue($people, $a), self::rankValue($people, $b));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
unset($ids);
|
||||||
|
|
||||||
|
return ['generations' => $generations, 'nodes' => $nodes, 'gen' => $gen];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关系类型的「英文键」——给前端画线用,不受中文称呼影响 */
|
||||||
|
public static function relationWord(array $g, int $me, int $other): string
|
||||||
|
{
|
||||||
|
if ($me === $other) {
|
||||||
|
return 'self';
|
||||||
|
}
|
||||||
|
if (in_array($other, self::spouseIds($g, $me), true)) {
|
||||||
|
return 'spouse';
|
||||||
|
}
|
||||||
|
if (self::parents($g, $me)['father'] === $other || self::parents($g, $me)['mother'] === $other) {
|
||||||
|
return 'parent';
|
||||||
|
}
|
||||||
|
if (in_array($other, self::childIds($g, $me), true)) {
|
||||||
|
return 'child';
|
||||||
|
}
|
||||||
|
if (in_array($other, self::siblingIds($g, $me), true)) {
|
||||||
|
return 'sibling';
|
||||||
|
}
|
||||||
|
foreach (self::childIds($g, $me) as $c) {
|
||||||
|
foreach (self::childIds($g, $c) as $gc) {
|
||||||
|
if ($gc === $other || in_array($other, self::childIds($g, $gc), true)) {
|
||||||
|
return 'grandchild';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (self::grandparents($g, $me) as $side) {
|
||||||
|
if ($side['father'] === $other || $side['mother'] === $other) {
|
||||||
|
return 'grandparent';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'kin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 id 列表变成 名字 => 称谓 的列表,供页面直接渲染 */
|
||||||
|
public static function describe(array $g, int $me, array $ids, array $people): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
if (isset($people[$id])) {
|
||||||
|
$out[] = [
|
||||||
|
'id' => (int)$id,
|
||||||
|
'name' => (string)$people[$id]['name'],
|
||||||
|
'label' => self::label($g, $me, (int)$id, $people),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-5
@@ -72,9 +72,10 @@ final class Person
|
|||||||
{
|
{
|
||||||
$phone = $d['phone'] ?? '';
|
$phone = $d['phone'] ?? '';
|
||||||
$sql = 'INSERT INTO persons
|
$sql = 'INSERT INTO persons
|
||||||
(family_id, kind, name, nickname, gender, birthday, avatar,
|
(family_id, kind, name, nickname, gender, birth_rank, birthday, avatar,
|
||||||
phone_hash, phone_encrypted, id_card_encrypted, email, address, note, sort_order)
|
phone_hash, phone_encrypted, id_card_encrypted, email, address, note,
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
|
is_placeholder, sort_order)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
|
||||||
$st = Database::get()->prepare($sql);
|
$st = Database::get()->prepare($sql);
|
||||||
$st->execute([
|
$st->execute([
|
||||||
$familyId,
|
$familyId,
|
||||||
@@ -82,6 +83,7 @@ final class Person
|
|||||||
$d['name'],
|
$d['name'],
|
||||||
$d['nickname'] !== '' ? $d['nickname'] : null,
|
$d['nickname'] !== '' ? $d['nickname'] : null,
|
||||||
$d['gender'] !== '' ? $d['gender'] : null,
|
$d['gender'] !== '' ? $d['gender'] : null,
|
||||||
|
!empty($d['birth_rank']) ? (int)$d['birth_rank'] : null,
|
||||||
$d['birthday'] !== '' ? $d['birthday'] : null,
|
$d['birthday'] !== '' ? $d['birthday'] : null,
|
||||||
$d['avatar'] !== '' ? $d['avatar'] : null,
|
$d['avatar'] !== '' ? $d['avatar'] : null,
|
||||||
$phone !== '' ? Crypto::hash($phone) : null,
|
$phone !== '' ? Crypto::hash($phone) : null,
|
||||||
@@ -90,6 +92,7 @@ final class Person
|
|||||||
$d['email'] !== '' ? $d['email'] : null,
|
$d['email'] !== '' ? $d['email'] : null,
|
||||||
$d['address'] !== '' ? $d['address'] : null,
|
$d['address'] !== '' ? $d['address'] : null,
|
||||||
$d['note'] !== '' ? $d['note'] : null,
|
$d['note'] !== '' ? $d['note'] : null,
|
||||||
|
!empty($d['is_placeholder']) ? 1 : 0,
|
||||||
(int)($d['sort_order'] ?? 1000),
|
(int)($d['sort_order'] ?? 1000),
|
||||||
]);
|
]);
|
||||||
return (int)Database::get()->lastInsertId();
|
return (int)Database::get()->lastInsertId();
|
||||||
@@ -99,14 +102,15 @@ final class Person
|
|||||||
{
|
{
|
||||||
$phone = $d['phone'] ?? '';
|
$phone = $d['phone'] ?? '';
|
||||||
$sql = 'UPDATE persons SET
|
$sql = 'UPDATE persons SET
|
||||||
name = ?, nickname = ?, gender = ?, birthday = ?, avatar = ?,
|
name = ?, nickname = ?, gender = ?, birth_rank = ?, birthday = ?, avatar = ?,
|
||||||
phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?,
|
phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?,
|
||||||
email = ?, address = ?, note = ?, sort_order = ?
|
email = ?, address = ?, note = ?, is_placeholder = ?, sort_order = ?
|
||||||
WHERE id = ?';
|
WHERE id = ?';
|
||||||
Database::get()->prepare($sql)->execute([
|
Database::get()->prepare($sql)->execute([
|
||||||
$d['name'],
|
$d['name'],
|
||||||
$d['nickname'] !== '' ? $d['nickname'] : null,
|
$d['nickname'] !== '' ? $d['nickname'] : null,
|
||||||
$d['gender'] !== '' ? $d['gender'] : null,
|
$d['gender'] !== '' ? $d['gender'] : null,
|
||||||
|
!empty($d['birth_rank']) ? (int)$d['birth_rank'] : null,
|
||||||
$d['birthday'] !== '' ? $d['birthday'] : null,
|
$d['birthday'] !== '' ? $d['birthday'] : null,
|
||||||
$d['avatar'] !== '' ? $d['avatar'] : null,
|
$d['avatar'] !== '' ? $d['avatar'] : null,
|
||||||
$phone !== '' ? Crypto::hash($phone) : null,
|
$phone !== '' ? Crypto::hash($phone) : null,
|
||||||
@@ -115,6 +119,7 @@ final class Person
|
|||||||
$d['email'] !== '' ? $d['email'] : null,
|
$d['email'] !== '' ? $d['email'] : null,
|
||||||
$d['address'] !== '' ? $d['address'] : null,
|
$d['address'] !== '' ? $d['address'] : null,
|
||||||
$d['note'] !== '' ? $d['note'] : null,
|
$d['note'] !== '' ? $d['note'] : null,
|
||||||
|
!empty($d['is_placeholder']) ? 1 : 0,
|
||||||
(int)($d['sort_order'] ?? 1000),
|
(int)($d['sort_order'] ?? 1000),
|
||||||
$id,
|
$id,
|
||||||
]);
|
]);
|
||||||
@@ -132,6 +137,30 @@ final class Person
|
|||||||
Database::get()->prepare('DELETE FROM persons WHERE id = ?')->execute([$id]);
|
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): array
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT id, name, nickname, gender, is_placeholder FROM persons
|
||||||
|
WHERE family_id = ? AND kind = "member" AND id <> ?
|
||||||
|
ORDER BY sort_order ASC, id ASC'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId, $exceptId]);
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 最近要过生日的人(首页用)。
|
* 最近要过生日的人(首页用)。
|
||||||
* 直接算「今天是今年的第几天」与生日的差值,跨年自动处理。
|
* 直接算「今天是今年的第几天」与生日的差值,跨年自动处理。
|
||||||
@@ -184,6 +213,16 @@ final class Person
|
|||||||
if ($idc !== '' && !preg_match('/^[0-9Xx]{15,18}$/', $idc)) {
|
if ($idc !== '' && !preg_match('/^[0-9Xx]{15,18}$/', $idc)) {
|
||||||
$err['id_card'] = '身份证号应为 15 或 18 位';
|
$err['id_card'] = '身份证号应为 15 或 18 位';
|
||||||
}
|
}
|
||||||
|
$rank = $d['birth_rank'] ?? '';
|
||||||
|
if ($rank !== '' && $rank !== null && (!ctype_digit((string)$rank) || (int)$rank < 1 || (int)$rank > 99)) {
|
||||||
|
$err['birth_rank'] = '排行请填 1-99 的整数(老大填 1)';
|
||||||
|
}
|
||||||
return $err;
|
return $err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 角色与人物的中文名 */
|
||||||
|
public static function genderWord(string $g): string
|
||||||
|
{
|
||||||
|
return ['m' => '男', 'f' => '女'][$g] ?? '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 亲属关系表的读写。
|
||||||
|
*
|
||||||
|
* 只维护两种边(详见 Kinship 的头注释):
|
||||||
|
* parent : from 是 to 的父/母
|
||||||
|
* spouse : from 与 to 是配偶(对称,只存一条)
|
||||||
|
*
|
||||||
|
* ⚠️ 血缘关系必须**防环**:如果把 A 设为 B 的父亲、又顺手把 B 设成 A 的父亲,
|
||||||
|
* 家谱展开会变成死循环。addParent() 在写库前先检查。
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Relations
|
||||||
|
{
|
||||||
|
/** 给某人指定一位父/母 */
|
||||||
|
public static function addParent(int $familyId, int $childId, int $parentId, string $role): array
|
||||||
|
{
|
||||||
|
$role = $role === 'mother' ? 'mother' : 'father';
|
||||||
|
if ($childId === $parentId) {
|
||||||
|
return ['ok' => false, 'error' => '不能把自己设成自己的父母。'];
|
||||||
|
}
|
||||||
|
if (self::wouldCycle($familyId, $parentId, $childId)) {
|
||||||
|
return ['ok' => false, 'error' => '这样会让两个人互为祖辈,关系说不通,已拒绝。'];
|
||||||
|
}
|
||||||
|
// 同一位父母只能有一个「父亲」和一个「母亲」位
|
||||||
|
$db = Database::get();
|
||||||
|
$st = $db->prepare(
|
||||||
|
'DELETE FROM relations WHERE family_id = ? AND to_id = ? AND kind = "parent" AND from_role = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId, $childId, $role]);
|
||||||
|
|
||||||
|
$st = $db->prepare(
|
||||||
|
'INSERT INTO relations (family_id, from_id, to_id, kind, from_role) VALUES (?,?,?,"parent",?)'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId, $parentId, $childId, $role]);
|
||||||
|
return ['ok' => true, 'error' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 互为配偶(对称,只存一条;若已存在反向的记录则视为重复) */
|
||||||
|
public static function addSpouse(int $familyId, int $a, int $b): array
|
||||||
|
{
|
||||||
|
if ($a === $b) {
|
||||||
|
return ['ok' => false, 'error' => '不能把自己设成自己的配偶。'];
|
||||||
|
}
|
||||||
|
$db = Database::get();
|
||||||
|
$st = $db->prepare(
|
||||||
|
'SELECT id FROM relations
|
||||||
|
WHERE family_id = ? AND kind = "spouse"
|
||||||
|
AND ((from_id = ? AND to_id = ?) OR (from_id = ? AND to_id = ?))'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId, $a, $b, $b, $a]);
|
||||||
|
if ($st->fetchColumn()) {
|
||||||
|
return ['ok' => true, 'error' => ''];
|
||||||
|
}
|
||||||
|
$st = $db->prepare(
|
||||||
|
'INSERT INTO relations (family_id, from_id, to_id, kind) VALUES (?,?,?,"spouse")'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId, $a, $b]);
|
||||||
|
return ['ok' => true, 'error' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解除某条关系(按 id) */
|
||||||
|
public static function remove(int $familyId, int $id): void
|
||||||
|
{
|
||||||
|
Database::get()->prepare('DELETE FROM relations WHERE id = ? AND family_id = ?')
|
||||||
|
->execute([$id, $familyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解除两人之间的某种关系 */
|
||||||
|
public static function detach(int $familyId, int $personId, string $kind, string $role = ''): void
|
||||||
|
{
|
||||||
|
$sql = 'DELETE FROM relations WHERE family_id = ? AND to_id = ? AND kind = ? AND from_role = ?';
|
||||||
|
Database::get()->prepare($sql)->execute([$familyId, $personId, $kind, $role]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 某人身上挂着的全部关系(详情页编辑用) */
|
||||||
|
public static function forPerson(int $familyId, int $personId): array
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT r.*,
|
||||||
|
pf.name AS from_name, pt.name AS to_name
|
||||||
|
FROM relations r
|
||||||
|
JOIN persons pf ON pf.id = r.from_id
|
||||||
|
JOIN persons pt ON pt.id = r.to_id
|
||||||
|
WHERE r.family_id = ? AND (r.from_id = ? OR r.to_id = ?)
|
||||||
|
ORDER BY r.kind, r.id'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId, $personId, $personId]);
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防环:若把 $maybeAncestor 挂到 $node 之上,会不会绕回自己?
|
||||||
|
* 做法是顺着 parent 边从 $maybeAncestor 往上走,看能否走到 $node。
|
||||||
|
*/
|
||||||
|
private static function wouldCycle(int $familyId, int $maybeAncestor, int $node): bool
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT from_id FROM relations WHERE family_id = ? AND to_id = ? AND kind = "parent"'
|
||||||
|
);
|
||||||
|
$stack = [$maybeAncestor];
|
||||||
|
$seen = [];
|
||||||
|
while ($stack) {
|
||||||
|
$cur = array_pop($stack);
|
||||||
|
if ($cur === $node) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isset($seen[$cur])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$cur] = true;
|
||||||
|
$st->execute([$familyId, $cur]);
|
||||||
|
foreach ($st->fetchAll(\PDO::FETCH_COLUMN) as $up) {
|
||||||
|
$stack[] = (int)$up;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全家的关系条数(设置页/统计用) */
|
||||||
|
public static function count(int $familyId): int
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare('SELECT COUNT(*) FROM relations WHERE family_id = ?');
|
||||||
|
$st->execute([$familyId]);
|
||||||
|
return (int)$st->fetchColumn();
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
-3
@@ -1,12 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 建表语句(安装向导与 tests 共用同一份)。
|
* 建表语句与结构升级(安装向导与运行时共用同一份)。
|
||||||
*
|
*
|
||||||
* 四条约定:
|
* 五条约定:
|
||||||
* 1. 全部 IF NOT EXISTS —— 重复执行不破坏已有数据
|
* 1. 全部 IF NOT EXISTS —— 重复执行不破坏已有数据
|
||||||
* 2. 字符集一律 utf8mb4_unicode_ci,emoji 与人名生僻字都不会乱码
|
* 2. 字符集一律 utf8mb4_unicode_ci,emoji 与人名生僻字都不会乱码
|
||||||
* 3. 一切业务数据挂在 family_id 下,将来要支持多个家庭也不必改模型
|
* 3. 一切业务数据挂在 family_id 下,将来要支持多个家庭也不必改模型
|
||||||
* 4. 手机号存两份:phone_hash 用于登录查找(确定性哈希),phone_encrypted 用于展示
|
* 4. 手机号存两份:phone_hash 用于登录查找(确定性哈希),phone_encrypted 用于展示
|
||||||
|
* 5. 结构升级走 ensure(),不靠人手动改库 —— 已上线的库也能自己长出新列
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@@ -36,6 +37,7 @@ CREATE TABLE IF NOT EXISTS `persons` (
|
|||||||
`name` VARCHAR(50) NOT NULL COMMENT '姓名',
|
`name` VARCHAR(50) NOT NULL COMMENT '姓名',
|
||||||
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '小名',
|
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '小名',
|
||||||
`gender` ENUM('m','f','x') DEFAULT NULL COMMENT '男 / 女 / 未填写',
|
`gender` ENUM('m','f','x') DEFAULT NULL COMMENT '男 / 女 / 未填写',
|
||||||
|
`birth_rank` INT DEFAULT NULL COMMENT '家中排行(同一对父母的子女里,1=老大),用来算长子/次女',
|
||||||
`birthday` DATE DEFAULT NULL COMMENT '生日(自动生成日历上的生日日程用)',
|
`birthday` DATE DEFAULT NULL COMMENT '生日(自动生成日历上的生日日程用)',
|
||||||
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像,相对于 uploads/avatars',
|
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像,相对于 uploads/avatars',
|
||||||
`phone_hash` CHAR(64) DEFAULT NULL COMMENT '手机号 sha256,登录查找用;NULL 不参与唯一约束',
|
`phone_hash` CHAR(64) DEFAULT NULL COMMENT '手机号 sha256,登录查找用;NULL 不参与唯一约束',
|
||||||
@@ -44,6 +46,7 @@ CREATE TABLE IF NOT EXISTS `persons` (
|
|||||||
`email` VARCHAR(120) DEFAULT NULL,
|
`email` VARCHAR(120) DEFAULT NULL,
|
||||||
`address` VARCHAR(200) DEFAULT NULL,
|
`address` VARCHAR(200) DEFAULT NULL,
|
||||||
`note` TEXT DEFAULT NULL COMMENT '备注',
|
`note` TEXT DEFAULT NULL COMMENT '备注',
|
||||||
|
`is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)',
|
||||||
`sort_order` INT NOT NULL DEFAULT 1000 COMMENT '名册排序,小的在前',
|
`sort_order` INT NOT NULL DEFAULT 1000 COMMENT '名册排序,小的在前',
|
||||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
@@ -58,7 +61,7 @@ CREATE TABLE IF NOT EXISTS `accounts` (
|
|||||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
`person_id` INT UNSIGNED NOT NULL COMMENT '一个人最多一个账号',
|
`person_id` INT UNSIGNED NOT NULL COMMENT '一个人最多一个账号',
|
||||||
`password_hash` VARCHAR(255) NOT NULL,
|
`password_hash` VARCHAR(255) NOT NULL,
|
||||||
`role` ENUM('admin','member') NOT NULL DEFAULT 'member' COMMENT '管理员 / 普通成员',
|
`role` ENUM('admin','member','guest') NOT NULL DEFAULT 'member' COMMENT '管理员 / 家庭成员 / 访客',
|
||||||
`status` ENUM('active','disabled') NOT NULL DEFAULT 'active',
|
`status` ENUM('active','disabled') NOT NULL DEFAULT 'active',
|
||||||
`last_login_at` DATETIME DEFAULT NULL,
|
`last_login_at` DATETIME DEFAULT NULL,
|
||||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -66,6 +69,25 @@ CREATE TABLE IF NOT EXISTS `accounts` (
|
|||||||
UNIQUE KEY `uk_account_person` (`person_id`),
|
UNIQUE KEY `uk_account_person` (`person_id`),
|
||||||
CONSTRAINT `fk_accounts_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
CONSTRAINT `fk_accounts_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录账号'
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录账号'
|
||||||
|
SQL,
|
||||||
|
<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS `relations` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`family_id` INT UNSIGNED NOT NULL,
|
||||||
|
`from_id` INT UNSIGNED NOT NULL COMMENT '关系的一端',
|
||||||
|
`to_id` INT UNSIGNED NOT NULL COMMENT '关系的另一端',
|
||||||
|
`kind` ENUM('parent','spouse') NOT NULL COMMENT 'parent: from 是 to 的父/母;spouse: 配偶(对称)',
|
||||||
|
`from_role` ENUM('father','mother') DEFAULT NULL COMMENT 'kind=parent 时必填:from 相对 to 是父亲还是母亲',
|
||||||
|
`note` VARCHAR(200) DEFAULT NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_rel` (`from_id`,`to_id`,`kind`,`from_role`),
|
||||||
|
KEY `idx_rel_from` (`from_id`,`kind`),
|
||||||
|
KEY `idx_rel_to` (`to_id`,`kind`),
|
||||||
|
CONSTRAINT `fk_rel_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_rel_from` FOREIGN KEY (`from_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_rel_to` FOREIGN KEY (`to_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='亲属关系:只存相邻两代之间的边,其余靠推导'
|
||||||
SQL,
|
SQL,
|
||||||
<<<'SQL'
|
<<<'SQL'
|
||||||
CREATE TABLE IF NOT EXISTS `settings` (
|
CREATE TABLE IF NOT EXISTS `settings` (
|
||||||
@@ -78,4 +100,55 @@ CREATE TABLE IF NOT EXISTS `settings` (
|
|||||||
SQL,
|
SQL,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等结构升级:建表 + 补齐后加的列/枚举值。
|
||||||
|
*
|
||||||
|
* ⚠️ CREATE TABLE IF NOT EXISTS 对**已存在的表**什么都不做 ——
|
||||||
|
* 所以给老库加列必须显式 ALTER,而且要先查 information_schema 再改,
|
||||||
|
* 否则第二次执行会报 Duplicate column。这就是「部署即可升级」的全部秘密。
|
||||||
|
*/
|
||||||
|
public static function ensure(): void
|
||||||
|
{
|
||||||
|
$db = Database::get();
|
||||||
|
foreach (self::statements() as $sql) {
|
||||||
|
$db->exec($sql);
|
||||||
|
}
|
||||||
|
self::addColumn($db, 'persons', 'birth_rank',
|
||||||
|
"ADD COLUMN `birth_rank` INT DEFAULT NULL COMMENT '家中排行(同一对父母的子女里,1=老大),用来算长子/次女' AFTER `gender`");
|
||||||
|
self::addColumn($db, 'persons', 'is_placeholder',
|
||||||
|
"ADD COLUMN `is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)' AFTER `note`");
|
||||||
|
self::widenRoleEnum($db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列不存在才加 */
|
||||||
|
private static function addColumn(\PDO $db, string $table, string $column, string $alter): void
|
||||||
|
{
|
||||||
|
$st = $db->prepare(
|
||||||
|
'SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$table, $column]);
|
||||||
|
if ((int)$st->fetchColumn() === 0) {
|
||||||
|
$db->exec("ALTER TABLE `{$table}` {$alter}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** accounts.role 原本只有 admin/member,升级时要把 guest 加进枚举 */
|
||||||
|
private static function widenRoleEnum(\PDO $db): void
|
||||||
|
{
|
||||||
|
$st = $db->prepare(
|
||||||
|
"SELECT COLUMN_TYPE FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'accounts' AND COLUMN_NAME = 'role'"
|
||||||
|
);
|
||||||
|
$st->execute();
|
||||||
|
$type = (string)$st->fetchColumn();
|
||||||
|
if ($type !== '' && strpos($type, 'guest') === false) {
|
||||||
|
$db->exec(
|
||||||
|
"ALTER TABLE `accounts` MODIFY COLUMN `role`
|
||||||
|
ENUM('admin','member','guest') NOT NULL DEFAULT 'member'
|
||||||
|
COMMENT '管理员 / 家庭成员 / 访客'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 阿里云 ECS 实例元数据:RAM 角色临时凭据 + 地域。
|
||||||
|
* (从 MovieLog_Server 移植)
|
||||||
|
*
|
||||||
|
* 为什么值得支持这种方式:这样**服务器上不用落任何长期密钥**。
|
||||||
|
* 实例绑一个 RAM 角色,程序现用现取 STS 临时凭据;即使站点被拖库,
|
||||||
|
* 拿到的也只是几十分钟就过期的临时 token。
|
||||||
|
*
|
||||||
|
* ⚠️ 一台 ECS 只能绑一个 RAM 角色;该角色必须被授权访问目标 Bucket,
|
||||||
|
* 否则报 AccessDenied(NoPermissionType=ImplicitDeny)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Storage;
|
||||||
|
|
||||||
|
use App\Setting;
|
||||||
|
|
||||||
|
final class EcsMeta
|
||||||
|
{
|
||||||
|
private const BASE = 'http://100.100.100.200/latest/meta-data/';
|
||||||
|
private const TIMEOUT = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取 RAM 角色的 STS 临时凭据。
|
||||||
|
* $roleName 留空则自动取实例绑定的第一个角色。
|
||||||
|
*/
|
||||||
|
public static function ramCredentials(int $familyId, string $roleName = '', bool $force = false): array
|
||||||
|
{
|
||||||
|
static $memo = [];
|
||||||
|
$memoKey = 'aliyun:' . $roleName;
|
||||||
|
if (!$force && isset($memo[$memoKey])) {
|
||||||
|
return $memo[$memoKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跨请求缓存:STS 一般 >= 1 小时,到期前 5 分钟就刷新
|
||||||
|
if (!$force) {
|
||||||
|
$cached = json_decode((string)Setting::get($familyId, 'cache_sts_aliyun', ''), true);
|
||||||
|
if (is_array($cached)
|
||||||
|
&& !empty($cached['expires']) && $cached['expires'] > time() + 300
|
||||||
|
&& ($cached['role'] ?? '') === $roleName) {
|
||||||
|
return $memo[$memoKey] = $cached + ['ok' => true, 'error' => ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMDSv2:先拿一次性 token(拿不到就降级走 v1,兼容老实例)
|
||||||
|
$token = '';
|
||||||
|
$t = Http::send('PUT', self::BASE . '../api/token',
|
||||||
|
['X-aliyun-ecs-metadata-token-ttl-seconds: 21600'], null, self::TIMEOUT);
|
||||||
|
if ($t['ok'] && trim((string)$t['body']) !== '') {
|
||||||
|
$token = trim($t['body']);
|
||||||
|
}
|
||||||
|
$auth = $token !== '' ? ["X-aliyun-ecs-metadata-token: {$token}"] : [];
|
||||||
|
|
||||||
|
$credsUrl = self::BASE . 'ram/security-credentials/';
|
||||||
|
if ($roleName === '') {
|
||||||
|
$r = Http::send('GET', $credsUrl, $auth, null, self::TIMEOUT);
|
||||||
|
if (!$r['ok']) {
|
||||||
|
return $memo[$memoKey] = self::fail('无法访问 ECS 元数据服务(这台服务器可能不是阿里云 ECS)');
|
||||||
|
}
|
||||||
|
$first = trim((string)strtok((string)$r['body'], "\r\n"));
|
||||||
|
if ($first === '') {
|
||||||
|
return $memo[$memoKey] = self::fail('该实例没有绑定 RAM 角色');
|
||||||
|
}
|
||||||
|
$roleName = $first;
|
||||||
|
}
|
||||||
|
|
||||||
|
$r = Http::send('GET', $credsUrl . rawurlencode($roleName), $auth, null, self::TIMEOUT);
|
||||||
|
if (!$r['ok']) {
|
||||||
|
return $memo[$memoKey] = self::fail("取 RAM 角色 {$roleName} 的临时凭据失败");
|
||||||
|
}
|
||||||
|
$json = json_decode((string)$r['body'], true);
|
||||||
|
if (!is_array($json) || empty($json['AccessKeyId']) || empty($json['AccessKeySecret'])) {
|
||||||
|
$code = is_array($json) ? (string)($json['Code'] ?? '') : '';
|
||||||
|
return $memo[$memoKey] = self::fail('元数据返回的内容看不懂' . ($code !== '' ? "({$code})" : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
$cred = [
|
||||||
|
'ok' => true,
|
||||||
|
'id' => (string)$json['AccessKeyId'],
|
||||||
|
'secret' => (string)$json['AccessKeySecret'],
|
||||||
|
'token' => (string)($json['SecurityToken'] ?? ''),
|
||||||
|
'expires' => !empty($json['Expiration']) ? (int)strtotime((string)$json['Expiration']) : time() + 1800,
|
||||||
|
'error' => '',
|
||||||
|
'role' => $roleName,
|
||||||
|
];
|
||||||
|
Setting::set($familyId, 'cache_sts_aliyun', json_encode($cred, JSON_UNESCAPED_UNICODE));
|
||||||
|
return $memo[$memoKey] = $cred;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本机地域,用于判断内网 endpoint 是否可用(1 小时缓存) */
|
||||||
|
public static function region(int $familyId, bool $force = false): string
|
||||||
|
{
|
||||||
|
static $memo = null;
|
||||||
|
if ($memo !== null && !$force) {
|
||||||
|
return $memo;
|
||||||
|
}
|
||||||
|
if (!$force) {
|
||||||
|
$c = json_decode((string)Setting::get($familyId, 'cache_ecs_region', ''), true);
|
||||||
|
if (is_array($c) && !empty($c['expires']) && $c['expires'] > time()) {
|
||||||
|
return $memo = (string)($c['region'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$r = Http::send('GET', self::BASE . 'region-id', [], null, self::TIMEOUT);
|
||||||
|
$region = $r['ok'] ? trim((string)$r['body']) : '';
|
||||||
|
Setting::set($familyId, 'cache_ecs_region',
|
||||||
|
json_encode(['region' => $region, 'expires' => time() + 3600]));
|
||||||
|
return $memo = $region;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清掉全部元数据缓存(换角色/改配置后调用) */
|
||||||
|
public static function clearCache(int $familyId): void
|
||||||
|
{
|
||||||
|
foreach (['cache_sts_aliyun', 'cache_ecs_region', 'cache_oss_internal'] as $k) {
|
||||||
|
Setting::set($familyId, $k, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function fail(string $msg): array
|
||||||
|
{
|
||||||
|
return ['ok' => false, 'id' => '', 'secret' => '', 'token' => '',
|
||||||
|
'expires' => 0, 'error' => $msg, 'role' => ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 对象存储·共用件:curl 客户端 + 若干编码工具。
|
||||||
|
* (从 MovieLog_Server 移植,去掉了与电影站相关的部分)
|
||||||
|
*
|
||||||
|
* ⚠️ 不依赖官方 SDK / Composer —— 全站手写签名 + curl,与 PHP 7.4+ 完全兼容。
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Storage;
|
||||||
|
|
||||||
|
final class Http
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 发一个 HTTP 请求。
|
||||||
|
*
|
||||||
|
* ⚠️ 千万不要用 CURLOPT_CUSTOMREQUEST='HEAD' 去发 HEAD:
|
||||||
|
* 这样 curl 仍会等 Content-Length 声明的 body,而服务端对 HEAD 不回体,
|
||||||
|
* 于是一路等到超时、curl_exec 返回 false。(MovieLog 实测 8011ms / errno=28)
|
||||||
|
* HEAD 要用 CURLOPT_NOBODY=true。
|
||||||
|
*/
|
||||||
|
public static function send(
|
||||||
|
string $method,
|
||||||
|
string $url,
|
||||||
|
array $headers = [],
|
||||||
|
$body = null,
|
||||||
|
int $timeout = 30,
|
||||||
|
?int $connectTimeout = null
|
||||||
|
): array {
|
||||||
|
$respHeaders = [];
|
||||||
|
$ch = curl_init();
|
||||||
|
$method = strtoupper($method);
|
||||||
|
$opts = [
|
||||||
|
CURLOPT_URL => $url,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => $connectTimeout === null ? min(10, max(3, $timeout)) : max(1, $connectTimeout),
|
||||||
|
CURLOPT_TIMEOUT => $timeout,
|
||||||
|
CURLOPT_FOLLOWLOCATION => false,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => 2,
|
||||||
|
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$respHeaders) {
|
||||||
|
$pos = strpos($line, ':');
|
||||||
|
if ($pos !== false) {
|
||||||
|
$name = strtolower(trim(substr($line, 0, $pos)));
|
||||||
|
$respHeaders[$name] = trim(substr($line, $pos + 1));
|
||||||
|
}
|
||||||
|
return strlen($line);
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if ($method === 'HEAD') {
|
||||||
|
$opts[CURLOPT_NOBODY] = true; // ← 必须,见上面注释
|
||||||
|
} else {
|
||||||
|
$opts[CURLOPT_CUSTOMREQUEST] = $method;
|
||||||
|
}
|
||||||
|
curl_setopt_array($ch, $opts);
|
||||||
|
|
||||||
|
$ca = self::caBundle();
|
||||||
|
if ($ca !== '') {
|
||||||
|
curl_setopt($ch, CURLOPT_CAINFO, $ca);
|
||||||
|
}
|
||||||
|
if ($body !== null && $method !== 'HEAD') {
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = curl_exec($ch);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
$errno = curl_errno($ch);
|
||||||
|
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($out === false) {
|
||||||
|
return [
|
||||||
|
'ok' => false, 'status' => 0, 'headers' => [], 'body' => '',
|
||||||
|
'error' => $errno === CURLE_OPERATION_TIMEDOUT
|
||||||
|
? "请求超时({$timeout}s)"
|
||||||
|
: "网络错误({$errno}): {$err}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'ok' => $status >= 200 && $status < 300,
|
||||||
|
'status' => $status,
|
||||||
|
'headers' => $respHeaders,
|
||||||
|
'body' => $out,
|
||||||
|
'error' => $status >= 200 && $status < 300 ? '' : "HTTP {$status}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 找系统的 CA 证书。宝塔的 open_basedir 会限制路径访问,
|
||||||
|
* 所以先判断「在不在允许范围内」再去 is_file,否则会刷 Warning 污染输出。
|
||||||
|
*/
|
||||||
|
public static function caBundle(): string
|
||||||
|
{
|
||||||
|
$candidates = [
|
||||||
|
'/etc/pki/tls/certs/ca-bundle.crt',
|
||||||
|
'/etc/ssl/certs/ca-certificates.crt',
|
||||||
|
'/etc/ssl/cert.pem',
|
||||||
|
ini_get('curl.cainfo'),
|
||||||
|
ini_get('openssl.cafile'),
|
||||||
|
];
|
||||||
|
$allowed = self::openBasedirPaths();
|
||||||
|
foreach ($candidates as $p) {
|
||||||
|
$p = (string)$p;
|
||||||
|
if ($p === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($allowed !== [] && !self::pathAllowed($p, $allowed)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (@is_file($p)) {
|
||||||
|
return $p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string[] */
|
||||||
|
private static function openBasedirPaths(): array
|
||||||
|
{
|
||||||
|
$raw = (string)ini_get('open_basedir');
|
||||||
|
if ($raw === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach (preg_split('/[:;]/', $raw) ?: [] as $p) {
|
||||||
|
if (trim($p) !== '') {
|
||||||
|
$out[] = rtrim(trim($p), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function pathAllowed(string $path, array $allowed): bool
|
||||||
|
{
|
||||||
|
foreach ($allowed as $base) {
|
||||||
|
if (strpos($path, $base) === 0 || strpos($base, $path) === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对象键的 URL 编码:保留 / 分段,其余按 RFC3986 编码 */
|
||||||
|
public static function encodeKey(string $key): string
|
||||||
|
{
|
||||||
|
$parts = explode('/', $key);
|
||||||
|
$parts = array_map('rawurlencode', $parts);
|
||||||
|
return implode('/', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把可能带协议的域名规整成 host[+port] */
|
||||||
|
public static function hostOf(string $domain): string
|
||||||
|
{
|
||||||
|
$domain = trim($domain);
|
||||||
|
if (strpos($domain, '//') !== false) {
|
||||||
|
$p = parse_url($domain, PHP_URL_HOST);
|
||||||
|
$domain = $p ?: $domain;
|
||||||
|
}
|
||||||
|
return trim($domain, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 错误信息压缩成一行,便于塞进页面提示 */
|
||||||
|
public static function shortError(array $res, int $limit = 200): string
|
||||||
|
{
|
||||||
|
$msg = (string)($res['error'] ?? '');
|
||||||
|
$body = (string)($res['body'] ?? '');
|
||||||
|
if ($body !== '' && stripos($body, '<Code>') !== false) {
|
||||||
|
$msg .= ' ' . trim(preg_replace('/\s+/', ' ', strip_tags($body)));
|
||||||
|
}
|
||||||
|
$msg = trim($msg);
|
||||||
|
return mb_strlen($msg) > $limit ? mb_substr($msg, 0, $limit) . '…' : $msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 本地磁盘驱动 —— 也是云端失败时的**回退目标**。
|
||||||
|
* 绝不因为云配置出问题就让用户丢文件。
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Storage;
|
||||||
|
|
||||||
|
final class LocalDriver implements StorageDriver
|
||||||
|
{
|
||||||
|
private string $root;
|
||||||
|
|
||||||
|
public function __construct(string $root)
|
||||||
|
{
|
||||||
|
$this->root = rtrim($root, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function id(): string
|
||||||
|
{
|
||||||
|
return 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateConfig(): string
|
||||||
|
{
|
||||||
|
return is_dir($this->root) || @mkdir($this->root, 0755, true)
|
||||||
|
? ''
|
||||||
|
: '本地存储目录不可写';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function abs(string $key): string
|
||||||
|
{
|
||||||
|
// 挡目录穿越:.. 一律拒绝
|
||||||
|
$key = ltrim(str_replace('\\', '/', $key), '/');
|
||||||
|
if (strpos($key, '..') !== false) {
|
||||||
|
return $this->root . '/__invalid__';
|
||||||
|
}
|
||||||
|
return $this->root . '/' . $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function putFile(string $srcPath, string $key, ?string $mime = null): array
|
||||||
|
{
|
||||||
|
$dst = $this->abs($key);
|
||||||
|
$dir = dirname($dst);
|
||||||
|
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
|
||||||
|
return ['ok' => false, 'error' => '建不了目录:' . $dir];
|
||||||
|
}
|
||||||
|
if (!@copy($srcPath, $dst)) {
|
||||||
|
return ['ok' => false, 'error' => '复制文件失败'];
|
||||||
|
}
|
||||||
|
@chmod($dst, 0644);
|
||||||
|
return ['ok' => true, 'error' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function putContents(string $binary, string $key, ?string $mime = null): array
|
||||||
|
{
|
||||||
|
$dst = $this->abs($key);
|
||||||
|
$dir = dirname($dst);
|
||||||
|
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
|
||||||
|
return ['ok' => false, 'error' => '建不了目录:' . $dir];
|
||||||
|
}
|
||||||
|
if (@file_put_contents($dst, $binary) === false) {
|
||||||
|
return ['ok' => false, 'error' => '写入文件失败'];
|
||||||
|
}
|
||||||
|
@chmod($dst, 0644);
|
||||||
|
return ['ok' => true, 'error' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $key): array
|
||||||
|
{
|
||||||
|
$p = $this->abs($key);
|
||||||
|
if (is_file($p) && !@unlink($p)) {
|
||||||
|
return ['ok' => false, 'error' => '删不掉:' . $key];
|
||||||
|
}
|
||||||
|
return ['ok' => true, 'error' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key, array $extra = []): array
|
||||||
|
{
|
||||||
|
$p = $this->abs($key);
|
||||||
|
if (!is_file($p)) {
|
||||||
|
return ['ok' => false, 'status' => 404, 'headers' => [], 'body' => '', 'error' => '文件不存在'];
|
||||||
|
}
|
||||||
|
$body = (string)@file_get_contents($p);
|
||||||
|
return ['ok' => true, 'status' => 200, 'headers' => [], 'body' => $body, 'error' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $key): bool
|
||||||
|
{
|
||||||
|
return is_file($this->abs($key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地文件返回站点相对路径(相对站点根,前面带 /) */
|
||||||
|
public function url(string $key): string
|
||||||
|
{
|
||||||
|
return '/' . ltrim(str_replace('\\', '/', $key), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 阿里云 OSS 驱动(手写签名 V1 / HMAC-SHA1,不依赖官方 SDK)。
|
||||||
|
* (从 MovieLog_Server 移植)
|
||||||
|
*
|
||||||
|
* ⚠️ 三件事必须记住:
|
||||||
|
* ① 写请求把 Content-Type 放进签名串,**读请求要传空串** —— 这是 OSS 签名的坑。
|
||||||
|
* ② 内网 endpoint 只用于**服务端读取**,对外 URL 永远用公网/自定义域名。
|
||||||
|
* 跨地域时内网域名能解析出 A 记录但永远连不上,会白等满一次连接超时,
|
||||||
|
* 所以先比地域(零成本),再只给 2 秒连接超时。
|
||||||
|
* ③ endpoint 填写时**不要带 https://**,且必须与桶所在地域一致,
|
||||||
|
* 否则报 "must be addressed using the specified endpoint"。
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Storage;
|
||||||
|
|
||||||
|
use App\Setting;
|
||||||
|
|
||||||
|
final class OssDriver implements StorageDriver
|
||||||
|
{
|
||||||
|
private array $cfg;
|
||||||
|
private int $familyId;
|
||||||
|
private ?array $cred = null;
|
||||||
|
|
||||||
|
public function __construct(int $familyId, array $cfg)
|
||||||
|
{
|
||||||
|
$this->familyId = $familyId;
|
||||||
|
$this->cfg = $cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function id(): string
|
||||||
|
{
|
||||||
|
return 'oss';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateConfig(): string
|
||||||
|
{
|
||||||
|
if (trim((string)$this->cfg['bucket']) === '') {
|
||||||
|
return '没有填 Bucket 名称';
|
||||||
|
}
|
||||||
|
if (trim((string)$this->cfg['endpoint']) === '') {
|
||||||
|
return '没有填 Endpoint';
|
||||||
|
}
|
||||||
|
$auth = ($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role'
|
||||||
|
|| (trim((string)($this->cfg['access_key_id'] ?? '')) !== ''
|
||||||
|
&& trim((string)($this->cfg['access_key_secret'] ?? '')) !== '');
|
||||||
|
if (!$auth) {
|
||||||
|
return '凭据不完整:要么填 AccessKey,要么选「用服务器角色」';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 凭据 ----------------
|
||||||
|
|
||||||
|
private function credentials(): array
|
||||||
|
{
|
||||||
|
if ($this->cred !== null) {
|
||||||
|
return $this->cred;
|
||||||
|
}
|
||||||
|
if (($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role') {
|
||||||
|
$c = EcsMeta::ramCredentials($this->familyId, (string)($this->cfg['ram_role_name'] ?? ''));
|
||||||
|
if (empty($c['ok'])) {
|
||||||
|
return $this->cred = ['ok' => false, 'error' => $c['error'] ?? '取临时凭据失败'];
|
||||||
|
}
|
||||||
|
return $this->cred = $c;
|
||||||
|
}
|
||||||
|
$id = trim((string)($this->cfg['access_key_id'] ?? ''));
|
||||||
|
$secret = trim((string)($this->cfg['access_key_secret'] ?? ''));
|
||||||
|
if ($id === '' || $secret === '') {
|
||||||
|
return $this->cred = ['ok' => false, 'error' => '没有填 AccessKey ID / AccessKey Secret'];
|
||||||
|
}
|
||||||
|
return $this->cred = ['ok' => true, 'id' => $id, 'secret' => $secret, 'token' => '', 'error' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- endpoint / 域名 ----------------
|
||||||
|
|
||||||
|
private function useHttps(): bool
|
||||||
|
{
|
||||||
|
return (string)($this->cfg['https'] ?? '1') !== '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scheme(): string
|
||||||
|
{
|
||||||
|
return $this->useHttps() ? 'https' : 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function endpoint(): string
|
||||||
|
{
|
||||||
|
return trim((string)$this->cfg['endpoint']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function host(): string
|
||||||
|
{
|
||||||
|
return trim((string)$this->cfg['bucket']) . '.' . $this->endpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内网 endpoint 只从标准域名推导,自定义/VPC 一律不猜 */
|
||||||
|
private function internalEndpoint(): string
|
||||||
|
{
|
||||||
|
if ((string)($this->cfg['internal_endpoint'] ?? '1') === '0') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$ep = $this->endpoint();
|
||||||
|
if ($ep === '' || strpos($ep, '-internal.') !== false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (preg_match('#^(oss-[a-z0-9-]+)\.aliyuncs\.com$#i', $ep, $m)) {
|
||||||
|
return $m[1] . '-internal.aliyuncs.com';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内网是否可用:先比地域(零成本),必要时才真探一次 */
|
||||||
|
private function internalUsable(): bool
|
||||||
|
{
|
||||||
|
$internal = $this->internalEndpoint();
|
||||||
|
if ($internal === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$cached = json_decode((string)Setting::get($this->familyId, 'cache_oss_internal', ''), true);
|
||||||
|
if (is_array($cached) && ($cached['endpoint'] ?? '') === $internal
|
||||||
|
&& !empty($cached['expires']) && $cached['expires'] > time()) {
|
||||||
|
return !empty($cached['ok']);
|
||||||
|
}
|
||||||
|
return $this->probeInternal($internal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function probeInternal(string $internal): bool
|
||||||
|
{
|
||||||
|
$ok = false;
|
||||||
|
$reason = '';
|
||||||
|
$bucketRegion = $this->bucketRegion();
|
||||||
|
$ecsRegion = EcsMeta::region($this->familyId);
|
||||||
|
|
||||||
|
if ($bucketRegion !== '' && $ecsRegion !== '' && $bucketRegion !== $ecsRegion) {
|
||||||
|
$reason = "本机在 {$ecsRegion}、桶在 {$bucketRegion},不是同一地域";
|
||||||
|
} else {
|
||||||
|
$cred = $this->credentials();
|
||||||
|
if (!empty($cred['ok'])) {
|
||||||
|
$url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $internal . '/';
|
||||||
|
// HEAD 探测:即使没 ListBucket 权限回 403,也证明网络可达
|
||||||
|
$res = Http::send('HEAD', $url, $this->readHeaders('/', $cred, [], 'HEAD'), null, 3, 2);
|
||||||
|
$ok = $res['status'] > 0;
|
||||||
|
$reason = $ok ? '' : ('连接失败:' . (string)($res['error'] ?? ''));
|
||||||
|
} else {
|
||||||
|
$reason = '凭据不可用';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Setting::set($this->familyId, 'cache_oss_internal', json_encode([
|
||||||
|
'endpoint' => $internal,
|
||||||
|
'ok' => $ok ? 1 : 0,
|
||||||
|
'reason' => $reason,
|
||||||
|
'expires' => time() + ($ok ? 3600 : 1800),
|
||||||
|
], JSON_UNESCAPED_UNICODE));
|
||||||
|
return $ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 桶所在地域(用 GetBucketLocation,失败则从 endpoint 推) */
|
||||||
|
private function bucketRegion(): string
|
||||||
|
{
|
||||||
|
if (preg_match('#^oss-([a-z0-9-]+)\.aliyuncs\.com$#i', $this->endpoint(), $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 签名 ----------------
|
||||||
|
|
||||||
|
private function authorization(string $method, string $key, string $contentType, string $date, array $cred): string
|
||||||
|
{
|
||||||
|
$canonicalHeaders = '';
|
||||||
|
if ((string)($cred['token'] ?? '') !== '') {
|
||||||
|
$canonicalHeaders = "x-oss-security-token:{$cred['token']}\n";
|
||||||
|
}
|
||||||
|
$canonicalResource = '/' . trim((string)$this->cfg['bucket']) . '/' . ltrim($key, '/');
|
||||||
|
$stringToSign = $method . "\n"
|
||||||
|
. "\n" // Content-MD5(未使用)
|
||||||
|
. $contentType . "\n"
|
||||||
|
. $date . "\n"
|
||||||
|
. $canonicalHeaders
|
||||||
|
. $canonicalResource;
|
||||||
|
$signature = base64_encode(hash_hmac('sha1', $stringToSign, $cred['secret'], true));
|
||||||
|
return 'OSS ' . $cred['id'] . ':' . $signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeHeaders(string $method, string $key, string $contentType, array $cred): array
|
||||||
|
{
|
||||||
|
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||||||
|
$h = [
|
||||||
|
'Date: ' . $date,
|
||||||
|
'Content-Type: ' . $contentType,
|
||||||
|
'Authorization: ' . $this->authorization($method, $key, $contentType, $date, $cred),
|
||||||
|
];
|
||||||
|
if ((string)($cred['token'] ?? '') !== '') {
|
||||||
|
$h[] = 'x-oss-security-token: ' . $cred['token'];
|
||||||
|
}
|
||||||
|
return $h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读请求刻意不发 Content-Type,签名串里传空串 */
|
||||||
|
private function readHeaders(string $key, array $cred, array $extra = [], string $method = 'GET'): array
|
||||||
|
{
|
||||||
|
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||||||
|
$h = [
|
||||||
|
'Date: ' . $date,
|
||||||
|
'Authorization: ' . $this->authorization($method, $key, '', $date, $cred),
|
||||||
|
];
|
||||||
|
if ((string)($cred['token'] ?? '') !== '') {
|
||||||
|
$h[] = 'x-oss-security-token: ' . $cred['token'];
|
||||||
|
}
|
||||||
|
foreach ($extra as $x) {
|
||||||
|
$h[] = $x;
|
||||||
|
}
|
||||||
|
return $h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 对象操作 ----------------
|
||||||
|
|
||||||
|
private function objectKey(string $key): string
|
||||||
|
{
|
||||||
|
$prefix = trim((string)($this->cfg['prefix'] ?? ''), '/');
|
||||||
|
$key = ltrim($key, '/');
|
||||||
|
return $prefix === '' ? $key : $prefix . '/' . $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function objectUrl(string $key): string
|
||||||
|
{
|
||||||
|
return $this->scheme() . '://' . $this->host() . '/' . Http::encodeKey($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function putFile(string $srcPath, string $key, ?string $mime = null): array
|
||||||
|
{
|
||||||
|
if (!is_file($srcPath)) {
|
||||||
|
return ['ok' => false, 'error' => '本地文件不存在'];
|
||||||
|
}
|
||||||
|
$binary = @file_get_contents($srcPath);
|
||||||
|
if ($binary === false) {
|
||||||
|
return ['ok' => false, 'error' => '读不出本地文件'];
|
||||||
|
}
|
||||||
|
return $this->putContents($binary, $key, $mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function putContents(string $binary, string $key, ?string $mime = null): array
|
||||||
|
{
|
||||||
|
if ($err = $this->validateConfig()) {
|
||||||
|
return ['ok' => false, 'error' => $err];
|
||||||
|
}
|
||||||
|
$cred = $this->credentials();
|
||||||
|
if (empty($cred['ok'])) {
|
||||||
|
return ['ok' => false, 'error' => $cred['error']];
|
||||||
|
}
|
||||||
|
$key = $this->objectKey($key);
|
||||||
|
$mime = $mime ?: 'application/octet-stream';
|
||||||
|
$res = Http::send('PUT', $this->objectUrl($key),
|
||||||
|
$this->writeHeaders('PUT', $key, $mime, $cred), $binary, 60);
|
||||||
|
|
||||||
|
if (!$res['ok']) {
|
||||||
|
return ['ok' => false, 'error' => '上传到 OSS 失败:' . Http::shortError($res)];
|
||||||
|
}
|
||||||
|
return ['ok' => true, 'error' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $key): array
|
||||||
|
{
|
||||||
|
$cred = $this->credentials();
|
||||||
|
if (empty($cred['ok'])) {
|
||||||
|
return ['ok' => false, 'error' => $cred['error']];
|
||||||
|
}
|
||||||
|
$key = $this->objectKey($key);
|
||||||
|
$res = Http::send('DELETE', $this->objectUrl($key),
|
||||||
|
$this->writeHeaders('DELETE', $key, '', $cred), null, 30);
|
||||||
|
|
||||||
|
if (!$res['ok'] && (int)$res['status'] !== 404) {
|
||||||
|
return ['ok' => false, 'error' => '删除 OSS 对象失败:' . Http::shortError($res)];
|
||||||
|
}
|
||||||
|
return ['ok' => true, 'error' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务端读取(内网优先 + 自动回退外网) */
|
||||||
|
public function get(string $key, array $extra = []): array
|
||||||
|
{
|
||||||
|
if ($err = $this->validateConfig()) {
|
||||||
|
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $err];
|
||||||
|
}
|
||||||
|
$cred = $this->credentials();
|
||||||
|
if (empty($cred['ok'])) {
|
||||||
|
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $cred['error']];
|
||||||
|
}
|
||||||
|
$key = $this->objectKey($key);
|
||||||
|
$headers = $this->readHeaders($key, $cred, $extra);
|
||||||
|
|
||||||
|
$hosts = [];
|
||||||
|
$internal = $this->internalEndpoint();
|
||||||
|
if ($internal !== '' && $this->internalUsable()) {
|
||||||
|
$hosts[] = $internal;
|
||||||
|
}
|
||||||
|
$hosts[] = $this->endpoint();
|
||||||
|
|
||||||
|
$res = null;
|
||||||
|
foreach ($hosts as $i => $host) {
|
||||||
|
$isInternal = ($internal !== '' && $host === $internal);
|
||||||
|
$url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $host
|
||||||
|
. '/' . Http::encodeKey($key);
|
||||||
|
$res = Http::send('GET', $url, $headers, null, 60, $isInternal ? 2 : null);
|
||||||
|
if ((int)$res['status'] === 0 && $isInternal && isset($hosts[$i + 1])) {
|
||||||
|
continue; // 内网连不上,换外网
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return $res ?: ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => '连不上 OSS'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $key): bool
|
||||||
|
{
|
||||||
|
$cred = $this->credentials();
|
||||||
|
if (empty($cred['ok'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$key = $this->objectKey($key);
|
||||||
|
$res = Http::send('HEAD', $this->objectUrl($key), $this->readHeaders($key, $cred, [], 'HEAD'), null, 15);
|
||||||
|
return (int)$res['status'] === 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对外地址:优先自定义域名/CDN */
|
||||||
|
public function url(string $key): string
|
||||||
|
{
|
||||||
|
$key = $this->objectKey($key);
|
||||||
|
$custom = trim((string)($this->cfg['custom_domain'] ?? ''));
|
||||||
|
if ($custom !== '') {
|
||||||
|
$host = Http::hostOf($custom);
|
||||||
|
return (strpos($custom, 'http') === 0 ? rtrim($custom, '/') : $this->scheme() . '://' . $host)
|
||||||
|
. '/' . Http::encodeKey($key);
|
||||||
|
}
|
||||||
|
return $this->objectUrl($key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 存储门面:全站**唯一**的附件读写出口。
|
||||||
|
*
|
||||||
|
* 三条纪律(照 MovieLog 的教训定,别绕过):
|
||||||
|
* ① 数据库只存「相对键」(如 avatars/ab12.jpg),绝不存完整 URL ——
|
||||||
|
* 换域名、换桶、换前缀都不用改历史数据。
|
||||||
|
* ② 页面绝不手拼路径,一律走 url()。
|
||||||
|
* ③ 云端失败**自动回退本地**,绝不因云配置问题丢用户的文件。
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Storage;
|
||||||
|
|
||||||
|
use App\Setting;
|
||||||
|
|
||||||
|
final class Storage
|
||||||
|
{
|
||||||
|
public const AVATAR_DIR = 'avatars';
|
||||||
|
|
||||||
|
/** OSS 默认配置模板 */
|
||||||
|
public static function ossDefaults(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'auth_mode' => 'ak', // ak | ecs_role
|
||||||
|
'access_key_id' => '',
|
||||||
|
'access_key_secret' => '',
|
||||||
|
'bucket' => '',
|
||||||
|
'endpoint' => '', // 例:oss-cn-beijing.aliyuncs.com(不带 https://)
|
||||||
|
'prefix' => '', // 桶内前缀,一个桶放多个站点时用
|
||||||
|
'ram_role_name' => '', // 留空 = 自动取实例绑定的角色
|
||||||
|
'custom_domain' => '', // 可选,CDN / 自定义域名
|
||||||
|
'internal_endpoint' => '1', // 服务端读取优先走内网
|
||||||
|
'https' => '1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 配置读写 ----------------
|
||||||
|
|
||||||
|
public static function driver(int $familyId): string
|
||||||
|
{
|
||||||
|
$d = (string)Setting::get($familyId, 'storage_driver', 'local');
|
||||||
|
return in_array($d, ['local', 'oss'], true) ? $d : 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function config(int $familyId): array
|
||||||
|
{
|
||||||
|
$raw = json_decode((string)Setting::get($familyId, 'storage_config_oss', ''), true);
|
||||||
|
$saved = is_array($raw) ? $raw : [];
|
||||||
|
return array_merge(self::ossDefaults(), $saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function saveConfig(int $familyId, string $driver, array $oss): void
|
||||||
|
{
|
||||||
|
$driver = in_array($driver, ['local', 'oss'], true) ? $driver : 'local';
|
||||||
|
Setting::set($familyId, 'storage_driver', $driver);
|
||||||
|
Setting::set($familyId, 'storage_config_oss',
|
||||||
|
json_encode(array_merge(self::ossDefaults(), $oss), JSON_UNESCAPED_UNICODE));
|
||||||
|
EcsMeta::clearCache($familyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 配置是否填齐 —— 没填齐就强制退回本地,避免「传到云端、地址却按本地算」的错配 */
|
||||||
|
public static function configComplete(int $familyId): bool
|
||||||
|
{
|
||||||
|
if (self::driver($familyId) !== 'oss') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$c = self::config($familyId);
|
||||||
|
$auth = ($c['auth_mode'] === 'ecs_role')
|
||||||
|
|| (trim((string)$c['access_key_id']) !== '' && trim((string)$c['access_key_secret']) !== '');
|
||||||
|
return $auth && trim((string)$c['bucket']) !== '' && trim((string)$c['endpoint']) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 实际生效的驱动(配置不全时是 local) */
|
||||||
|
public static function effectiveDriver(int $familyId): string
|
||||||
|
{
|
||||||
|
$want = self::driver($familyId);
|
||||||
|
if ($want !== 'local' && !self::configComplete($familyId)) {
|
||||||
|
return 'local';
|
||||||
|
}
|
||||||
|
return $want;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ?StorageDriver $instance = null;
|
||||||
|
private static ?int $instanceFor = null;
|
||||||
|
|
||||||
|
public static function disk(int $familyId): StorageDriver
|
||||||
|
{
|
||||||
|
$id = self::effectiveDriver($familyId);
|
||||||
|
if (self::$instance === null || self::$instanceFor !== $familyId || self::$instance->id() !== $id) {
|
||||||
|
self::$instance = $id === 'oss'
|
||||||
|
? new OssDriver($familyId, self::config($familyId))
|
||||||
|
: new LocalDriver(FB_UPLOADS);
|
||||||
|
self::$instanceFor = $familyId;
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 读写 ----------------
|
||||||
|
|
||||||
|
/** 存一个上传的文件;云端失败自动回退本地 */
|
||||||
|
public static function putFile(int $familyId, string $srcPath, string $key, ?string $mime = null): array
|
||||||
|
{
|
||||||
|
$disk = self::disk($familyId);
|
||||||
|
$r = $disk->putFile($srcPath, $key, $mime);
|
||||||
|
if (!empty($r['ok'])) {
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
if ($disk->id() !== 'local') {
|
||||||
|
$local = new LocalDriver(FB_UPLOADS);
|
||||||
|
$l = $local->putFile($srcPath, $key, $mime);
|
||||||
|
if (!empty($l['ok'])) {
|
||||||
|
return ['ok' => true, 'error' => '', 'note' => '云端上传失败,已自动存到本地:' . $r['error']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function putContents(int $familyId, string $binary, string $key, ?string $mime = null): array
|
||||||
|
{
|
||||||
|
$disk = self::disk($familyId);
|
||||||
|
$r = $disk->putContents($binary, $key, $mime);
|
||||||
|
if (!empty($r['ok'])) {
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
if ($disk->id() !== 'local') {
|
||||||
|
$local = new LocalDriver(FB_UPLOADS);
|
||||||
|
$l = $local->putContents($binary, $key, $mime);
|
||||||
|
if (!empty($l['ok'])) {
|
||||||
|
return ['ok' => true, 'error' => '', 'note' => '云端上传失败,已自动存到本地:' . $r['error']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function delete(int $familyId, string $key): void
|
||||||
|
{
|
||||||
|
if ($key === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 两个地方都试一遍:历史文件可能只在本地
|
||||||
|
self::disk($familyId)->delete($key);
|
||||||
|
if (self::effectiveDriver($familyId) !== 'local') {
|
||||||
|
(new LocalDriver(FB_UPLOADS))->delete($key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取内容(导出、代理等场景) */
|
||||||
|
public static function read(int $familyId, string $key): array
|
||||||
|
{
|
||||||
|
return self::disk($familyId)->get($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function exists(int $familyId, string $key): bool
|
||||||
|
{
|
||||||
|
if ($key === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (is_file(FB_UPLOADS . '/' . $key)) {
|
||||||
|
return true; // 本地优先(迁移期两者并存)
|
||||||
|
}
|
||||||
|
return self::disk($familyId)->exists($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **唯一出口**:把相对键变成可访问地址。
|
||||||
|
*
|
||||||
|
* 读取顺序刻意设计成「本地优先」:
|
||||||
|
* ① 已是绝对地址 → 原样返回
|
||||||
|
* ② 本地有文件 → 走本地(迁移期老文件仍可访问,随时可回滚)
|
||||||
|
* ③ 交给当前驱动
|
||||||
|
*/
|
||||||
|
public static function url(int $familyId, ?string $key): string
|
||||||
|
{
|
||||||
|
$key = trim((string)$key);
|
||||||
|
if ($key === '') {
|
||||||
|
return asset('/assets/avatar-default.svg');
|
||||||
|
}
|
||||||
|
if (preg_match('#^https?://#i', $key)) {
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
$key = ltrim($key, '/');
|
||||||
|
$local = FB_UPLOADS . '/' . $key;
|
||||||
|
if (is_file($local)) {
|
||||||
|
return asset('/uploads/' . $key);
|
||||||
|
}
|
||||||
|
$disk = self::disk($familyId);
|
||||||
|
if ($disk->id() === 'local') {
|
||||||
|
return asset('/uploads/' . $key); // 本地也没有 → 让默认头像逻辑兜住
|
||||||
|
}
|
||||||
|
return $disk->url($key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 存储驱动接口。三种实现:local / oss / null(未配置)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Storage;
|
||||||
|
|
||||||
|
interface StorageDriver
|
||||||
|
{
|
||||||
|
public function id(): string;
|
||||||
|
|
||||||
|
/** 配置是否可用;返回空串表示可用 */
|
||||||
|
public function validateConfig(): string;
|
||||||
|
|
||||||
|
public function putFile(string $srcPath, string $key, ?string $mime = null): array;
|
||||||
|
|
||||||
|
public function putContents(string $binary, string $key, ?string $mime = null): array;
|
||||||
|
|
||||||
|
public function delete(string $key): array;
|
||||||
|
|
||||||
|
public function get(string $key, array $extra = []): array;
|
||||||
|
|
||||||
|
public function exists(string $key): bool;
|
||||||
|
|
||||||
|
/** 对外可访问地址 */
|
||||||
|
public function url(string $key): string;
|
||||||
|
}
|
||||||
+51
-32
@@ -1,26 +1,37 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 头像上传:裁成正方形、缩到 512×512、转成 JPEG 落盘。
|
* 头像上传:裁成正方形、缩到 512×512、转成 JPEG,然后交给 Storage 落盘。
|
||||||
|
*
|
||||||
|
* 落盘位置由「设置」决定(本地 或 阿里云 OSS),本类不关心。
|
||||||
*
|
*
|
||||||
* 安全要点:
|
* 安全要点:
|
||||||
* - 用 finfo 判断真实 MIME,不信浏览器给的扩展名
|
* - 用 finfo 判断真实 MIME,不信浏览器给的扩展名
|
||||||
* - 文件名随机,绝不使用用户提供的名字(防路径穿越与扩展名绕过)
|
* - 文件名随机,绝不使用用户提供的名字(防路径穿越与扩展名绕过)
|
||||||
* - 只写进 uploads/avatars/,该目录有 .htaccess 禁止执行脚本
|
* - 本地落盘时进 uploads/,该目录有 .htaccess 禁止执行脚本
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App;
|
namespace App;
|
||||||
|
|
||||||
|
use App\Storage\Storage;
|
||||||
|
|
||||||
final class Upload
|
final class Upload
|
||||||
{
|
{
|
||||||
private const MAX_BYTES = 5 * 1024 * 1024;
|
private const MAX_BYTES = 5 * 1024 * 1024;
|
||||||
private const SIZE = 512;
|
private const SIZE = 512;
|
||||||
|
|
||||||
/** @return array{ok:bool,file?:string,error?:string} */
|
/** @return array{ok:bool,file?:string,error?:string} */
|
||||||
public static function avatar(array $file): array
|
public static function avatar(int $familyId, array $file): array
|
||||||
{
|
{
|
||||||
if (!isset($file['error']) || is_array($file['error'])) {
|
// 「表单里压根没有这个字段」= 用户没打算换头像,不是错误。
|
||||||
|
// ⚠️ 早先这里一律报「上传参数不对」,于是任何**不带 enctype 多部分**的
|
||||||
|
// 调用(或没有该字段的表单)都会被判成校验失败,整条新增/编辑被挡下,
|
||||||
|
// 表现却是「表单提交后又回到表单、什么都没说」—— 很难查。
|
||||||
|
if (!array_key_exists('error', $file)) {
|
||||||
|
return ['ok' => false, 'error' => ''];
|
||||||
|
}
|
||||||
|
if (is_array($file['error'])) {
|
||||||
return ['ok' => false, 'error' => '上传参数不对'];
|
return ['ok' => false, 'error' => '上传参数不对'];
|
||||||
}
|
}
|
||||||
switch ($file['error']) {
|
switch ($file['error']) {
|
||||||
@@ -54,7 +65,6 @@ final class Upload
|
|||||||
|
|
||||||
$w = imagesx($src);
|
$w = imagesx($src);
|
||||||
$h = imagesy($src);
|
$h = imagesy($src);
|
||||||
// 居中裁成正方形
|
|
||||||
$side = min($w, $h);
|
$side = min($w, $h);
|
||||||
$sx = (int)(($w - $side) / 2);
|
$sx = (int)(($w - $side) / 2);
|
||||||
$sy = (int)(($h - $side) / 2);
|
$sy = (int)(($h - $side) / 2);
|
||||||
@@ -64,42 +74,51 @@ final class Upload
|
|||||||
$bg = imagecolorallocate($canvas, 253, 251, 245);
|
$bg = imagecolorallocate($canvas, 253, 251, 245);
|
||||||
imagefilledrectangle($canvas, 0, 0, self::SIZE, self::SIZE, $bg);
|
imagefilledrectangle($canvas, 0, 0, self::SIZE, self::SIZE, $bg);
|
||||||
imagecopyresampled($canvas, $src, 0, 0, $sx, $sy, self::SIZE, self::SIZE, $side, $side);
|
imagecopyresampled($canvas, $src, 0, 0, $sx, $sy, self::SIZE, self::SIZE, $side, $side);
|
||||||
|
|
||||||
$dir = FB_UPLOADS . '/avatars';
|
|
||||||
if (!is_dir($dir)) {
|
|
||||||
mkdir($dir, 0755, true);
|
|
||||||
}
|
|
||||||
$name = bin2hex(random_bytes(12)) . '.jpg';
|
|
||||||
if (!imagejpeg($canvas, $dir . '/' . $name, 88)) {
|
|
||||||
imagedestroy($canvas);
|
|
||||||
imagedestroy($src);
|
|
||||||
return ['ok' => false, 'error' => '保存失败'];
|
|
||||||
}
|
|
||||||
imagedestroy($canvas);
|
|
||||||
imagedestroy($src);
|
imagedestroy($src);
|
||||||
|
|
||||||
return ['ok' => true, 'file' => 'avatars/' . $name];
|
// 先在临时目录生成,再整体交给 Storage —— 这样本地与 OSS 是同一条路径
|
||||||
|
$tmpOut = tempnam(sys_get_temp_dir(), 'fbav');
|
||||||
|
if ($tmpOut === false || !imagejpeg($canvas, $tmpOut, 88)) {
|
||||||
|
imagedestroy($canvas);
|
||||||
|
@unlink((string)$tmpOut);
|
||||||
|
return ['ok' => false, 'error' => '处理图片失败'];
|
||||||
|
}
|
||||||
|
imagedestroy($canvas);
|
||||||
|
|
||||||
|
$key = Storage::AVATAR_DIR . '/' . bin2hex(random_bytes(12)) . '.jpg';
|
||||||
|
$res = Storage::putFile($familyId, $tmpOut, $key, 'image/jpeg');
|
||||||
|
@unlink($tmpOut);
|
||||||
|
|
||||||
|
if (empty($res['ok'])) {
|
||||||
|
return ['ok' => false, 'error' => '保存失败:' . (string)($res['error'] ?? '')];
|
||||||
|
}
|
||||||
|
$out = ['ok' => true, 'file' => $key];
|
||||||
|
if (!empty($res['note'])) {
|
||||||
|
$out['note'] = (string)$res['note'];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 头像访问地址(无头像时回退到内置剪影,保证永远不出现破图) */
|
/** 头像访问地址(无头像时回退内置剪影,保证永远不出现破图) */
|
||||||
public static function avatarUrl(?string $rel): string
|
public static function avatarUrl(int $familyId, ?string $rel): string
|
||||||
{
|
{
|
||||||
if ($rel && is_file(FB_UPLOADS . '/' . $rel)) {
|
$rel = trim((string)$rel);
|
||||||
$p = FB_UPLOADS . '/' . $rel;
|
if ($rel === '') {
|
||||||
// 注意:filemtime() 返回 int,本文件开了 strict_types,直接喂给 md5() 会 TypeError
|
return asset('/assets/avatar-default.svg');
|
||||||
return 'uploads/' . $rel . '?v=' . substr(md5(filemtime($p) . filesize($p)), 0, 8);
|
|
||||||
}
|
}
|
||||||
return asset('/assets/avatar-default.svg');
|
return Storage::url($familyId, $rel);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function remove(?string $rel): void
|
public static function remove(int $familyId, ?string $rel): void
|
||||||
{
|
{
|
||||||
if (!$rel) {
|
if ($rel) {
|
||||||
return;
|
Storage::delete($familyId, (string)$rel);
|
||||||
}
|
|
||||||
$p = FB_UPLOADS . '/' . $rel;
|
|
||||||
if (is_file($p)) {
|
|
||||||
@unlink($p);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 让 Storage 知道自己该用哪个驱动(测试/迁移用) */
|
||||||
|
public static function driverId(int $familyId): string
|
||||||
|
{
|
||||||
|
return Storage::effectiveDriver($familyId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+10
-2
@@ -28,15 +28,23 @@ final class View
|
|||||||
require dirname(__DIR__) . '/templates/layout.php';
|
require dirname(__DIR__) . '/templates/layout.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 独立的整页(登录、安装、错误),不套顶栏 */
|
/**
|
||||||
|
* 独立的整页(登录、安装、错误),不套顶栏 —— 但**仍然要是一份完整 HTML**。
|
||||||
|
*
|
||||||
|
* ⚠️ 曾经这里直接 require $file,输出里没有 doctype / head / CSS,
|
||||||
|
* 浏览器拿默认样式渲染,登录页看起来就像「完全没样式」。
|
||||||
|
* 现在统一套一层 layout-bare.php(无顶栏的最小骨架)。
|
||||||
|
*/
|
||||||
public static function renderBare(string $tpl, array $data = []): void
|
public static function renderBare(string $tpl, array $data = []): void
|
||||||
{
|
{
|
||||||
$file = dirname(__DIR__) . '/templates/' . $tpl . '.php';
|
$file = dirname(__DIR__) . '/templates/' . $tpl . '.php';
|
||||||
if (!is_file($file)) {
|
if (!is_file($file)) {
|
||||||
throw new \RuntimeException('模板不存在:' . $tpl);
|
throw new \RuntimeException('模板不存在:' . $tpl);
|
||||||
}
|
}
|
||||||
|
$tplFile = $file;
|
||||||
$pageTitle = $data['title'] ?? '家书';
|
$pageTitle = $data['title'] ?? '家书';
|
||||||
|
$extraHead = $data['extraHead'] ?? '';
|
||||||
extract($data, EXTR_SKIP);
|
extract($data, EXTR_SKIP);
|
||||||
require $file;
|
require dirname(__DIR__) . '/templates/layout-bare.php';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ spl_autoload_register(function ($class) {
|
|||||||
if (strpos($class, 'App\\') !== 0) {
|
if (strpos($class, 'App\\') !== 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// App\Storage\OssDriver → app/Storage/OssDriver.php(子命名空间靠这段映射)
|
||||||
$rel = str_replace('\\', '/', substr($class, 4));
|
$rel = str_replace('\\', '/', substr($class, 4));
|
||||||
$file = __DIR__ . '/' . $rel . '.php';
|
$file = __DIR__ . '/' . $rel . '.php';
|
||||||
if (is_file($file)) {
|
if (is_file($file)) {
|
||||||
@@ -49,6 +50,14 @@ ini_set('log_errors', '1');
|
|||||||
// ---------------- 时区:日期是家书的核心数据,一开始就必须明确 ----------------
|
// ---------------- 时区:日期是家书的核心数据,一开始就必须明确 ----------------
|
||||||
date_default_timezone_set($GLOBALS['config']['timezone'] ?? 'Asia/Shanghai');
|
date_default_timezone_set($GLOBALS['config']['timezone'] ?? 'Asia/Shanghai');
|
||||||
|
|
||||||
|
// ---------------- 安全响应头 ----------------
|
||||||
|
// Referrer-Policy: 跨域取图不带 Referer —— 这一条能兜住云存储防盗链
|
||||||
|
// 导致的「图片不显示、但地址栏直接打开没问题」那类怪异现象。
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('Referrer-Policy: same-origin');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- 会话 ----------------
|
// ---------------- 会话 ----------------
|
||||||
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||||
|
|||||||
+28
-1
@@ -21,6 +21,23 @@ function asset(string $rel): string
|
|||||||
return $rel . '?v=' . substr(md5(filemtime($p) . filesize($p)), 0, 8);
|
return $rel . '?v=' . substr(md5(filemtime($p) . filesize($p)), 0, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件(头像等)地址 —— 全站唯一出口。
|
||||||
|
* 走这里是纪律:数据库只存相对键,云端/本地的差异由 Storage 自己吸收。
|
||||||
|
*/
|
||||||
|
function storage_url(?string $key): string
|
||||||
|
{
|
||||||
|
$fid = \App\Auth::familyId();
|
||||||
|
return \App\Storage\Storage::url((int)$fid, $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 头像地址(无头像自动回退内置剪影) */
|
||||||
|
function avatar_url(?string $key): string
|
||||||
|
{
|
||||||
|
$fid = (int)\App\Auth::familyId();
|
||||||
|
return \App\Upload::avatarUrl($fid, $key);
|
||||||
|
}
|
||||||
|
|
||||||
/** 站内链接(统一走 query-string 路由,不依赖 Apache 重写) */
|
/** 站内链接(统一走 query-string 路由,不依赖 Apache 重写) */
|
||||||
function url(string $route = 'home', array $query = []): string
|
function url(string $route = 'home', array $query = []): string
|
||||||
{
|
{
|
||||||
@@ -39,7 +56,17 @@ function is_post(): bool
|
|||||||
return ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST';
|
return ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST';
|
||||||
}
|
}
|
||||||
|
|
||||||
function fb_config(string $key = null, $default = null)
|
/**
|
||||||
|
* 读配置。
|
||||||
|
*
|
||||||
|
* ⚠️ `?string $key = null` 里的 `?` 不能省:PHP 8.4 起,
|
||||||
|
* 「有类型 + 默认 null」而没写 `?` 的形式(隐式可空)会报 Deprecated。
|
||||||
|
* 这个警告一旦在页面顶部打出来,就等于响应体已经输出 ——
|
||||||
|
* 后面所有 header() 全部失效(典型症状:安装完/登录后**不再跳转**,
|
||||||
|
* 返回 200 而不是 302,但功能其实是好的,极难排查)。
|
||||||
|
* 线上跑 PHP 7.4/8.1 时不报,所以本地用新 PHP 才抓得到。
|
||||||
|
*/
|
||||||
|
function fb_config(?string $key = null, $default = null)
|
||||||
{
|
{
|
||||||
$c = $GLOBALS['config'];
|
$c = $GLOBALS['config'];
|
||||||
if ($key === null) {
|
if ($key === null) {
|
||||||
|
|||||||
+123
-3
@@ -246,13 +246,133 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
.search { display: flex; gap: 8px; margin-bottom: 16px; }
|
.search { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||||
.search .input { max-width: 260px; }
|
.search .input { max-width: 260px; }
|
||||||
|
|
||||||
|
/* ---------------- 称谓标签 ---------------- */
|
||||||
|
.rel-label {
|
||||||
|
display: inline-block; padding: 1px 9px; border-radius: 999px;
|
||||||
|
background: #F6E3D7; color: var(--brand-ink);
|
||||||
|
font-size: 11px; font-weight: 500; line-height: 1.8;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 家谱(以我为中心) ---------------- */
|
||||||
|
.tree { display: flex; flex-direction: column; align-items: center; gap: 0; }
|
||||||
|
.tree-gen { width: 100%; }
|
||||||
|
.tree-gen-label {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
margin: 0 0 8px; padding-left: 2px;
|
||||||
|
}
|
||||||
|
.tree-gen-label .gname { font-size: 13px; font-weight: 600; color: var(--ink); }
|
||||||
|
.tree-gen-label .gtag {
|
||||||
|
font-size: 11px; color: var(--muted); background: var(--line-2);
|
||||||
|
padding: 1px 8px; border-radius: 999px;
|
||||||
|
}
|
||||||
|
.tree-row {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 10px;
|
||||||
|
justify-content: center; align-items: stretch;
|
||||||
|
}
|
||||||
|
.tnode {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 5px;
|
||||||
|
width: 104px; padding: 12px 8px 10px; text-align: center;
|
||||||
|
background: var(--surface); border: 1px solid var(--line);
|
||||||
|
border-radius: 14px; box-shadow: var(--shadow);
|
||||||
|
color: var(--ink); transition: transform .15s ease, box-shadow .15s ease;
|
||||||
|
}
|
||||||
|
.tnode:hover { transform: translateY(-2px); box-shadow: var(--shadow-l); text-decoration: none; }
|
||||||
|
.tnode .tph { width: 52px; height: 52px; border-radius: 50%; overflow: hidden; background: #F1E7D6; }
|
||||||
|
.tnode .tph img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.tnode .tname { font-size: 13px; font-weight: 600; line-height: 1.3; }
|
||||||
|
.tnode .tlabel { font-size: 11px; color: var(--brand); }
|
||||||
|
.tnode .tph-mark { font-size: 10px; color: var(--faint); }
|
||||||
|
.tnode.is-me {
|
||||||
|
border-color: var(--brand);
|
||||||
|
box-shadow: 0 0 0 3px rgba(168, 74, 40, .12), var(--shadow);
|
||||||
|
}
|
||||||
|
.tnode.is-me .tlabel { color: var(--brand-ink); font-weight: 600; }
|
||||||
|
.tnode.is-placeholder { border-style: dashed; }
|
||||||
|
/* 世代之间的连接线(纯装饰,说明"往下走一代") */
|
||||||
|
.tree-link {
|
||||||
|
width: 2px; height: 22px; background: linear-gradient(var(--line), var(--line-2));
|
||||||
|
margin: 6px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 详情页里的迷你家谱 */
|
||||||
|
.mini-tree { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.mini-gen { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.mini-node {
|
||||||
|
display: flex; align-items: center; gap: 7px;
|
||||||
|
padding: 5px 12px 5px 5px; border-radius: 999px;
|
||||||
|
background: #FAF7F1; border: 1px solid var(--line);
|
||||||
|
color: var(--ink); font-size: 13px;
|
||||||
|
}
|
||||||
|
.mini-node:hover { text-decoration: none; border-color: var(--brand); }
|
||||||
|
.mini-node img { width: 26px; height: 26px; border-radius: 50%; object-fit: cover; }
|
||||||
|
.mini-node .mn-label { font-size: 11px; color: var(--brand); }
|
||||||
|
.mini-node.is-me { border-color: var(--brand); background: #F6E3D7; }
|
||||||
|
|
||||||
|
/* ---------------- 亲属关系编辑 ---------------- */
|
||||||
|
.rel-summary {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px; margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.rel-sum-item {
|
||||||
|
background: #FAF7F1; border: 1px solid var(--line-2);
|
||||||
|
border-radius: var(--radius-s); padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.rel-sum-k { display: block; font-size: 12px; color: var(--muted); margin-bottom: 3px; }
|
||||||
|
.rel-sum-v { font-size: 14px; }
|
||||||
|
.rel-sum-v a { margin-right: 6px; }
|
||||||
|
|
||||||
|
.rel-add { border-top: 1px solid var(--line-2); padding-top: 16px; }
|
||||||
|
.rel-add-title { font-size: 14px; font-weight: 600; margin: 0 0 12px; }
|
||||||
|
.rel-form {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr 1.4fr auto;
|
||||||
|
gap: 12px; align-items: end;
|
||||||
|
}
|
||||||
|
.rel-list { margin-top: 16px; font-size: 13px; }
|
||||||
|
.rel-list summary { cursor: pointer; color: var(--muted); }
|
||||||
|
.rel-list ul { margin: 10px 0 0; padding-left: 18px; }
|
||||||
|
.rel-list li { margin-bottom: 6px; }
|
||||||
|
.rel-list .btn { margin-left: 6px; }
|
||||||
|
|
||||||
|
/* ---------------- 三角色 / 存储设置 ---------------- */
|
||||||
|
.role-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
||||||
|
.role-card { background: #FAF7F1; border: 1px solid var(--line-2); border-radius: var(--radius-s); padding: 14px 16px; }
|
||||||
|
.rc-name { font-weight: 600; margin-bottom: 5px; }
|
||||||
|
.rc-desc { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.65; }
|
||||||
|
|
||||||
|
.radio-row { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.radio-item {
|
||||||
|
display: flex; gap: 10px; align-items: flex-start;
|
||||||
|
background: #FAF7F1; border: 1px solid var(--line-2);
|
||||||
|
border-radius: var(--radius-s); padding: 12px 14px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.radio-item input { margin-top: 3px; }
|
||||||
|
.check-item { display: flex; gap: 8px; align-items: flex-start; font-size: 13px; cursor: pointer; }
|
||||||
|
.check-item input { margin-top: 3px; }
|
||||||
|
|
||||||
|
.oss-box {
|
||||||
|
border: 1px solid var(--line-2); border-radius: var(--radius-s);
|
||||||
|
padding: 12px 16px; background: #FCFAF6;
|
||||||
|
}
|
||||||
|
.oss-box summary { cursor: pointer; font-size: 13px; font-weight: 600; color: var(--muted); }
|
||||||
|
|
||||||
|
.plain-list { margin: 0; padding-left: 18px; font-size: 14px; }
|
||||||
|
.plain-list li { margin-bottom: 7px; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.rel-form { grid-template-columns: 1fr; }
|
||||||
|
.tnode { width: 88px; padding: 9px 6px 8px; }
|
||||||
|
.tnode .tph { width: 44px; height: 44px; }
|
||||||
|
.tree-row { gap: 8px; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
/* 390px 宽度放不下整套顶栏:只留 图标 + 导航 + 头像 + 退出 */
|
/* 390px 宽度放不下整套顶栏:只留 图标 + 导航 + 头像 + 退出 */
|
||||||
.topbar-inner { gap: 8px; padding: 0 12px; height: 54px; }
|
.topbar-inner { gap: 6px; padding: 0 10px; height: 54px; }
|
||||||
.brand img { width: 26px; height: 26px; }
|
.brand img { width: 26px; height: 26px; }
|
||||||
.nav { gap: 0; }
|
.nav { gap: 0; }
|
||||||
.nav a { padding: 6px 9px; font-size: 14px; }
|
.nav a { padding: 6px 8px; font-size: 13px; }
|
||||||
.topbar-right { gap: 8px; }
|
.topbar-right { gap: 6px; }
|
||||||
.who-name, .who-pwd { display: none; } /* 名字与「改密码」收起,设置页里都有 */
|
.who-name, .who-pwd { display: none; } /* 名字与「改密码」收起,设置页里都有 */
|
||||||
.who img { width: 26px; height: 26px; }
|
.who img { width: 26px; height: 26px; }
|
||||||
.container, .container-narrow { padding: 20px 16px 48px; }
|
.container, .container-narrow { padding: 20px 16px 48px; }
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ use App\Auth;
|
|||||||
use App\Crypto;
|
use App\Crypto;
|
||||||
use App\Database;
|
use App\Database;
|
||||||
use App\Family;
|
use App\Family;
|
||||||
|
use App\Kinship;
|
||||||
use App\Person;
|
use App\Person;
|
||||||
|
use App\Relations;
|
||||||
|
use App\Schema;
|
||||||
use App\Setting;
|
use App\Setting;
|
||||||
use App\Upload;
|
use App\Upload;
|
||||||
use App\View;
|
use App\View;
|
||||||
@@ -25,6 +28,40 @@ if (!Database::hasTables()) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次性结构升级。
|
||||||
|
*
|
||||||
|
* ⚠️ 为什么要有这段:上线后的库不认识新加的列(birth_rank / is_placeholder)和
|
||||||
|
* 新的 guest 角色。靠人去手动 ALTER 迟早会漏,所以在每次请求的开头比一下版本号,
|
||||||
|
* 落后了就补一次 —— 平时只是一次字符串比较,几乎零成本。
|
||||||
|
*/
|
||||||
|
const FB_SCHEMA_VERSION = '2';
|
||||||
|
if (Database::hasTables()) {
|
||||||
|
$familyIdForSchema = isset($_SESSION['family_id']) ? (int)$_SESSION['family_id'] : 0;
|
||||||
|
// 登录前还没有 family_id,用首个家庭兜底(本系统是单家庭部署)
|
||||||
|
if ($familyIdForSchema === 0) {
|
||||||
|
try {
|
||||||
|
$familyIdForSchema = (int)(Database::get()->query('SELECT MIN(id) FROM families')->fetchColumn() ?: 0);
|
||||||
|
} catch (\Throwable $ex) {
|
||||||
|
$familyIdForSchema = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($familyIdForSchema > 0) {
|
||||||
|
$have = (string)Setting::get($familyIdForSchema, 'schema_version', '1');
|
||||||
|
if ($have !== FB_SCHEMA_VERSION) {
|
||||||
|
try {
|
||||||
|
Schema::ensure();
|
||||||
|
Setting::set($familyIdForSchema, 'schema_version', FB_SCHEMA_VERSION);
|
||||||
|
} catch (\Throwable $ex) {
|
||||||
|
// 升级失败不能把整站挡住;下次请求会再试
|
||||||
|
if (!empty($GLOBALS['config']['debug'])) {
|
||||||
|
throw $ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$r = isset($_GET['r']) ? trim((string)$_GET['r'], '/') : 'home';
|
$r = isset($_GET['r']) ? trim((string)$_GET['r'], '/') : 'home';
|
||||||
$r = $r === '' ? 'home' : $r;
|
$r = $r === '' ? 'home' : $r;
|
||||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
@@ -101,29 +138,88 @@ switch ($r) {
|
|||||||
View::render('auth/password', ['title' => '修改密码'], 'home');
|
View::render('auth/password', ['title' => '修改密码'], 'home');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ==================== 首页 ====================
|
// ==================== 首页(以我为中心) ====================
|
||||||
case 'home':
|
case 'home':
|
||||||
$familyId = (int)Auth::familyId();
|
$familyId = (int)Auth::familyId();
|
||||||
|
$me = (int)Auth::personId();
|
||||||
|
$people = Person::map($familyId);
|
||||||
|
$graph = Kinship::graph($familyId);
|
||||||
|
|
||||||
|
// 「我的家人」= 以我为中心上下各三代那张家谱里的人。
|
||||||
|
// ⚠️ 早先只取「直接关系」(父母/配偶/子女/兄弟姐妹),于是**姻亲与祖孙都被漏掉**:
|
||||||
|
// 爱人登进来看不到「公公婆婆」,我看不到「岳父岳母」,爷爷奶奶也不在首页上。
|
||||||
|
// 这恰恰是「以个人为中心」最该体现的地方,所以改用家谱的节点集合。
|
||||||
|
$tree = Kinship::tree($graph, $me, $people, 3, 3);
|
||||||
|
$kinIds = array_diff(array_map('intval', array_keys($tree['nodes'])), [$me]);
|
||||||
|
$kin = [];
|
||||||
|
// 用 Person::all()(带账号信息)而不是 Person::map(),首页才能显示管理员/访客徽标
|
||||||
|
foreach (Person::all($familyId) as $p) {
|
||||||
|
$pid = (int)$p['id'];
|
||||||
|
if (in_array($pid, $kinIds, true)) {
|
||||||
|
$kin[$pid] = $p + ['label' => Kinship::label($graph, $me, $pid, $people)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$birthdays = Person::upcomingBirthdays($familyId);
|
||||||
|
// 生日条上也标出称谓
|
||||||
|
foreach ($birthdays as &$b) {
|
||||||
|
$b['label'] = isset($people[(int)$b['id']])
|
||||||
|
? Kinship::label($graph, $me, (int)$b['id'], $people) : '';
|
||||||
|
}
|
||||||
|
unset($b);
|
||||||
|
|
||||||
View::render('home', [
|
View::render('home', [
|
||||||
'title' => '首页',
|
'title' => '首页',
|
||||||
'family' => Family::find($familyId),
|
'family' => Family::find($familyId),
|
||||||
'people' => Person::all($familyId),
|
'me' => $people[$me] ?? null,
|
||||||
'birthdays' => Person::upcomingBirthdays($familyId),
|
'meId' => $me,
|
||||||
|
'kin' => $kin,
|
||||||
|
'allCount' => count($people),
|
||||||
|
'relCount' => Relations::count($familyId),
|
||||||
|
'birthdays' => $birthdays,
|
||||||
], 'home');
|
], 'home');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// ==================== 家谱(以我为中心) ====================
|
||||||
|
case 'kin':
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$me = (int)Auth::personId();
|
||||||
|
$people = Person::map($familyId);
|
||||||
|
$graph = Kinship::graph($familyId);
|
||||||
|
$tree = Kinship::tree($graph, $me, $people, 3, 3);
|
||||||
|
|
||||||
|
View::render('kin/tree', [
|
||||||
|
'title' => '我的家谱',
|
||||||
|
'tree' => $tree,
|
||||||
|
'meId' => $me,
|
||||||
|
'people' => $people,
|
||||||
|
'graph' => $graph,
|
||||||
|
], 'kin');
|
||||||
|
break;
|
||||||
|
|
||||||
// ==================== 名册 ====================
|
// ==================== 名册 ====================
|
||||||
case 'people':
|
case 'people':
|
||||||
$q = trim((string)($_GET['q'] ?? ''));
|
$q = trim((string)($_GET['q'] ?? ''));
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$me = (int)Auth::personId();
|
||||||
|
$rows = Person::all($familyId, $q);
|
||||||
|
$people = Person::map($familyId);
|
||||||
|
$graph = Kinship::graph($familyId);
|
||||||
|
// 名册里也标出「我该怎么称呼他」
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$row['label'] = Kinship::label($graph, $me, (int)$row['id'], $people);
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
View::render('people/index', [
|
View::render('people/index', [
|
||||||
'title' => '家庭名册',
|
'title' => '家庭名册',
|
||||||
'people' => Person::all((int)Auth::familyId(), $q),
|
'people' => $rows,
|
||||||
'q' => $q,
|
'q' => $q,
|
||||||
|
'meId' => $me,
|
||||||
], 'people');
|
], 'people');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'people/new':
|
case 'people/new':
|
||||||
Auth::requireAdmin();
|
Auth::requireWrite();
|
||||||
clear_old();
|
clear_old();
|
||||||
View::render('people/form', [
|
View::render('people/form', [
|
||||||
'title' => '添加成员',
|
'title' => '添加成员',
|
||||||
@@ -133,7 +229,7 @@ switch ($r) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'people/create':
|
case 'people/create':
|
||||||
Auth::requireAdmin();
|
Auth::requireWrite();
|
||||||
csrf_check();
|
csrf_check();
|
||||||
$d = [
|
$d = [
|
||||||
'name' => trim((string)($_POST['name'] ?? '')),
|
'name' => trim((string)($_POST['name'] ?? '')),
|
||||||
@@ -148,10 +244,14 @@ switch ($r) {
|
|||||||
'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
|
'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
|
||||||
'avatar' => '',
|
'avatar' => '',
|
||||||
'kind' => 'member',
|
'kind' => 'member',
|
||||||
|
// ⚠️ 这两项必须显式从表单取回:表单里有,但漏了这一步就会被静默丢掉,
|
||||||
|
// 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。
|
||||||
|
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')),
|
||||||
|
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
|
||||||
];
|
];
|
||||||
$errors = Person::validate($d);
|
$errors = Person::validate($d);
|
||||||
|
|
||||||
$up = Upload::avatar($_FILES['avatar'] ?? []);
|
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []);
|
||||||
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
|
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
|
||||||
$errors['avatar'] = $up['error'];
|
$errors['avatar'] = $up['error'];
|
||||||
}
|
}
|
||||||
@@ -188,7 +288,7 @@ switch ($r) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'people/edit':
|
case 'people/edit':
|
||||||
Auth::requireAdmin();
|
Auth::requireWrite();
|
||||||
$p = Person::find((int)($_GET['id'] ?? 0));
|
$p = Person::find((int)($_GET['id'] ?? 0));
|
||||||
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
|
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
|
||||||
flash('err', '找不到这个人。');
|
flash('err', '找不到这个人。');
|
||||||
@@ -205,7 +305,7 @@ switch ($r) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'people/update':
|
case 'people/update':
|
||||||
Auth::requireAdmin();
|
Auth::requireWrite();
|
||||||
csrf_check();
|
csrf_check();
|
||||||
$id = (int)($_POST['id'] ?? 0);
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
$p = Person::find($id);
|
$p = Person::find($id);
|
||||||
@@ -225,9 +325,11 @@ switch ($r) {
|
|||||||
'note' => trim((string)($_POST['note'] ?? '')),
|
'note' => trim((string)($_POST['note'] ?? '')),
|
||||||
'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
|
'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
|
||||||
'avatar' => (string)$p['avatar'],
|
'avatar' => (string)$p['avatar'],
|
||||||
|
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')),
|
||||||
|
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
|
||||||
];
|
];
|
||||||
$errors = Person::validate($d);
|
$errors = Person::validate($d);
|
||||||
$up = Upload::avatar($_FILES['avatar'] ?? []);
|
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []);
|
||||||
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
|
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
|
||||||
$errors['avatar'] = $up['error'];
|
$errors['avatar'] = $up['error'];
|
||||||
}
|
}
|
||||||
@@ -243,7 +345,7 @@ switch ($r) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
if ($up['ok']) {
|
if ($up['ok']) {
|
||||||
Upload::remove((string)$p['avatar']);
|
Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
|
||||||
$d['avatar'] = $up['file'];
|
$d['avatar'] = $up['file'];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -268,14 +370,33 @@ switch ($r) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'people/show':
|
case 'people/show':
|
||||||
$p = Person::findWithAccount((int)($_GET['id'] ?? 0));
|
$familyId = (int)Auth::familyId();
|
||||||
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
|
$me = (int)Auth::personId();
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
$p = Person::findWithAccount($id);
|
||||||
|
if (!$p || (int)$p['family_id'] !== $familyId) {
|
||||||
flash('err', '找不到这个人。');
|
flash('err', '找不到这个人。');
|
||||||
redirect('people');
|
redirect('people');
|
||||||
}
|
}
|
||||||
|
$people = Person::map($familyId);
|
||||||
|
$graph = Kinship::graph($familyId);
|
||||||
|
|
||||||
|
// 这个人在「我」眼里是谁,以及我与他之间的直接关系(可编辑的那些)
|
||||||
|
$tree = Kinship::tree($graph, $id, $people, 2, 2);
|
||||||
|
|
||||||
View::render('people/show', [
|
View::render('people/show', [
|
||||||
'title' => $p['name'],
|
'title' => $p['name'],
|
||||||
'person' => $p,
|
'person' => $p,
|
||||||
|
'meId' => $me,
|
||||||
|
'people' => $people,
|
||||||
|
'graph' => $graph,
|
||||||
|
'labelToMe' => Kinship::label($graph, $me, $id, $people),
|
||||||
|
'isMe' => $id === $me,
|
||||||
|
// 以这个人为中心的两代亲属,供详情页展示
|
||||||
|
'theirTree' => $tree,
|
||||||
|
'theirRels' => Relations::forPerson($familyId, $id),
|
||||||
|
'options' => Person::options($familyId, $id),
|
||||||
|
'kinReady' => Kinship::graph($familyId) !== [],
|
||||||
], 'people');
|
], 'people');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -295,7 +416,7 @@ switch ($r) {
|
|||||||
flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。');
|
flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。');
|
||||||
redirect('people/show', ['id' => $id]);
|
redirect('people/show', ['id' => $id]);
|
||||||
}
|
}
|
||||||
Upload::remove((string)$p['avatar']);
|
Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
|
||||||
Person::delete($id);
|
Person::delete($id);
|
||||||
flash('ok', '已从名册移除「' . $p['name'] . '」。');
|
flash('ok', '已从名册移除「' . $p['name'] . '」。');
|
||||||
}
|
}
|
||||||
@@ -326,7 +447,8 @@ switch ($r) {
|
|||||||
flash('err', implode(';', $errs));
|
flash('err', implode(';', $errs));
|
||||||
redirect('people/show', ['id' => $personId]);
|
redirect('people/show', ['id' => $personId]);
|
||||||
}
|
}
|
||||||
Account::create($personId, $pass, ($_POST['role'] ?? '') === 'admin' ? 'admin' : 'member');
|
$r0 = (string)($_POST['role'] ?? '');
|
||||||
|
Account::create($personId, $pass, in_array($r0, Account::ROLES, true) ? $r0 : 'member');
|
||||||
flash('ok', '账号已开通,用手机号就能登录。');
|
flash('ok', '账号已开通,用手机号就能登录。');
|
||||||
redirect('people/show', ['id' => $personId]);
|
redirect('people/show', ['id' => $personId]);
|
||||||
break;
|
break;
|
||||||
@@ -335,7 +457,7 @@ switch ($r) {
|
|||||||
Auth::requireAdmin();
|
Auth::requireAdmin();
|
||||||
csrf_check();
|
csrf_check();
|
||||||
$personId = (int)($_POST['person_id'] ?? 0);
|
$personId = (int)($_POST['person_id'] ?? 0);
|
||||||
$role = ($_POST['role'] ?? '') === 'admin' ? 'admin' : 'member';
|
$role = in_array(($r1 = (string)($_POST['role'] ?? '')), Account::ROLES, true) ? $r1 : 'member';
|
||||||
$p = Person::find($personId);
|
$p = Person::find($personId);
|
||||||
$acc = Account::findByPerson($personId);
|
$acc = Account::findByPerson($personId);
|
||||||
if ($p && $acc) {
|
if ($p && $acc) {
|
||||||
@@ -370,6 +492,109 @@ switch ($r) {
|
|||||||
redirect('people/show', ['id' => $personId]);
|
redirect('people/show', ['id' => $personId]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// ==================== 亲属关系 ====================
|
||||||
|
case 'kin/relate':
|
||||||
|
Auth::requireWrite();
|
||||||
|
csrf_check();
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$from = (int)($_POST['from_id'] ?? 0); // 我(或当前正在编辑的人)
|
||||||
|
$to = (int)($_POST['to_id'] ?? 0); // 对方
|
||||||
|
$kind = (string)($_POST['kind'] ?? '');
|
||||||
|
$role = (string)($_POST['role'] ?? '');
|
||||||
|
|
||||||
|
$ok = Person::find($from) && Person::find($to)
|
||||||
|
&& (int)Person::find($from)['family_id'] === $familyId
|
||||||
|
&& (int)Person::find($to)['family_id'] === $familyId;
|
||||||
|
if (!$ok) {
|
||||||
|
flash('err', '选的人不对。');
|
||||||
|
redirect('people');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️⚠️ 方向极易写反,而且写反了**不会报任何错**:页面照常显示「关系已记录」,
|
||||||
|
// 只是把人记成了对方的父母 —— 家谱整个倒过来。
|
||||||
|
// 约定:表单里 from_id 是「当前打开的人」,也就是表单上写的那个「我」;
|
||||||
|
// kind=parent 表示「对方是我的父母」→ 所以我是 child、对方是 parent。
|
||||||
|
// Relations::addParent($familyId, $childId, $parentId, $role) 的第二个参数是**孩子**。
|
||||||
|
if ($kind === 'parent') {
|
||||||
|
// 对方是我的父母:我是孩子,对方是父母
|
||||||
|
$r = Relations::addParent($familyId, $from, $to, $role);
|
||||||
|
} elseif ($kind === 'child') {
|
||||||
|
// 对方是我的子女:对方是孩子,我是父母
|
||||||
|
$r = Relations::addParent($familyId, $to, $from, $role);
|
||||||
|
} else {
|
||||||
|
$r = Relations::addSpouse($familyId, $from, $to);
|
||||||
|
}
|
||||||
|
flash($r['ok'] ? 'ok' : 'err', $r['ok'] ? '关系已记录。' : $r['error']);
|
||||||
|
redirect('people/show', ['id' => $from]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'kin/unrelate':
|
||||||
|
Auth::requireWrite();
|
||||||
|
csrf_check();
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$who = (int)($_POST['person_id'] ?? 0);
|
||||||
|
Relations::remove($familyId, $id);
|
||||||
|
flash('ok', '这条关系已解除。');
|
||||||
|
redirect('people/show', ['id' => $who]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ==================== 访客邀请 ====================
|
||||||
|
case 'accounts/invite':
|
||||||
|
Auth::requireAdmin();
|
||||||
|
clear_old();
|
||||||
|
View::render('people/invite', [
|
||||||
|
'title' => '邀请访客',
|
||||||
|
'people' => Person::all((int)Auth::familyId()),
|
||||||
|
'options' => Person::options((int)Auth::familyId()),
|
||||||
|
], 'people');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'accounts/invite_create':
|
||||||
|
Auth::requireAdmin();
|
||||||
|
csrf_check();
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$name = trim((string)($_POST['name'] ?? ''));
|
||||||
|
$phone = trim((string)($_POST['phone'] ?? ''));
|
||||||
|
$pass = (string)($_POST['password'] ?? '');
|
||||||
|
$note = trim((string)($_POST['note'] ?? ''));
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
if ($name === '') {
|
||||||
|
$errors['name'] = '请填写对方的名字';
|
||||||
|
}
|
||||||
|
if (!preg_match('/^\+?[0-9\-]{6,20}$/', $phone)) {
|
||||||
|
$errors['phone'] = '手机号格式看起来不对';
|
||||||
|
}
|
||||||
|
$pw = Account::validatePassword($pass);
|
||||||
|
if ($pw) {
|
||||||
|
$errors['password'] = implode(';', $pw);
|
||||||
|
}
|
||||||
|
if (!$errors) {
|
||||||
|
try {
|
||||||
|
// 访客也进名册(kind=contact,与家庭成员区分开),但不参与家谱
|
||||||
|
$pid = Person::create($familyId, [
|
||||||
|
'name' => $name, 'nickname' => '', 'gender' => '', 'birthday' => '',
|
||||||
|
'avatar' => '', 'phone' => $phone, 'id_card' => '', 'email' => '',
|
||||||
|
'address' => '', 'note' => $note, 'sort_order' => 2000, 'kind' => 'contact',
|
||||||
|
]);
|
||||||
|
Account::create($pid, $pass, 'guest');
|
||||||
|
clear_old();
|
||||||
|
flash('ok', '访客「' . $name . '」已开通,把手机号和密码告诉他就能登录。');
|
||||||
|
redirect('accounts/invite');
|
||||||
|
} catch (\PDOException $ex) {
|
||||||
|
$errors['phone'] = '这个手机号已经有人用了';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set_old($_POST);
|
||||||
|
View::render('people/invite', [
|
||||||
|
'title' => '邀请访客',
|
||||||
|
'people' => Person::all($familyId),
|
||||||
|
'options' => Person::options($familyId),
|
||||||
|
'errors' => $errors,
|
||||||
|
], 'people');
|
||||||
|
break;
|
||||||
|
|
||||||
// ==================== 设置 ====================
|
// ==================== 设置 ====================
|
||||||
case 'settings':
|
case 'settings':
|
||||||
Auth::requireAdmin();
|
Auth::requireAdmin();
|
||||||
@@ -380,14 +605,77 @@ switch ($r) {
|
|||||||
// 拿不到版本不影响页面
|
// 拿不到版本不影响页面
|
||||||
}
|
}
|
||||||
$rawKey = (string)fb_config('app_key', '');
|
$rawKey = (string)fb_config('app_key', '');
|
||||||
|
$acc = Account::roleCounts();
|
||||||
View::render('settings', [
|
View::render('settings', [
|
||||||
'title' => '设置',
|
'title' => '设置',
|
||||||
'family' => Family::find((int)Auth::familyId()),
|
'family' => Family::find((int)Auth::familyId()),
|
||||||
'dbVersion' => $dbVersion,
|
'dbVersion' => $dbVersion,
|
||||||
'keyOk' => $rawKey !== '' && strpos($rawKey, '请填写') !== 0,
|
'keyOk' => $rawKey !== '' && strpos($rawKey, '请填写') !== 0,
|
||||||
|
'roleCounts' => $acc,
|
||||||
|
'relCount' => Relations::count((int)Auth::familyId()),
|
||||||
|
'storageDriver' => \App\Storage\Storage::driver((int)Auth::familyId()),
|
||||||
|
'storageOss' => \App\Storage\Storage::config((int)Auth::familyId()),
|
||||||
|
'storageOk' => \App\Storage\Storage::configComplete((int)Auth::familyId()),
|
||||||
|
'storageEffective' => \App\Storage\Storage::effectiveDriver((int)Auth::familyId()),
|
||||||
], 'settings');
|
], 'settings');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'settings/storage':
|
||||||
|
Auth::requireAdmin();
|
||||||
|
csrf_check();
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$old = \App\Storage\Storage::config($familyId);
|
||||||
|
$in = (array)($_POST['oss'] ?? []);
|
||||||
|
// 密钥留空 = 不改(避免「保存时没重填就把已存的密钥清掉」)
|
||||||
|
foreach (['access_key_secret', 'access_key_id'] as $k) {
|
||||||
|
if (trim((string)($in[$k] ?? '')) === '') {
|
||||||
|
$in[$k] = (string)$old[$k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$oss = [
|
||||||
|
'auth_mode' => ($in['auth_mode'] ?? 'ak') === 'ecs_role' ? 'ecs_role' : 'ak',
|
||||||
|
'access_key_id' => trim((string)($in['access_key_id'] ?? '')),
|
||||||
|
'access_key_secret' => trim((string)($in['access_key_secret'] ?? '')),
|
||||||
|
'bucket' => trim((string)($in['bucket'] ?? '')),
|
||||||
|
'endpoint' => trim((string)($in['endpoint'] ?? '')),
|
||||||
|
'prefix' => trim((string)($in['prefix'] ?? '')),
|
||||||
|
'ram_role_name' => trim((string)($in['ram_role_name'] ?? '')),
|
||||||
|
'custom_domain' => trim((string)($in['custom_domain'] ?? '')),
|
||||||
|
'internal_endpoint' => !empty($in['internal_endpoint']) ? '1' : '0',
|
||||||
|
'https' => !empty($in['https']) ? '1' : '0',
|
||||||
|
];
|
||||||
|
\App\Storage\Storage::saveConfig($familyId, (string)($_POST['driver'] ?? 'local'), $oss);
|
||||||
|
flash('ok', '附件存储设置已保存。');
|
||||||
|
redirect('settings');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'settings/test_storage':
|
||||||
|
Auth::requireAdmin();
|
||||||
|
csrf_check();
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$disk = \App\Storage\Storage::disk($familyId);
|
||||||
|
$err = $disk->validateConfig();
|
||||||
|
if ($err !== '') {
|
||||||
|
flash('err', '配置不完整:' . $err);
|
||||||
|
redirect('settings');
|
||||||
|
}
|
||||||
|
// 真写一个探针对象再读回来,最后删掉 —— 比只 ping 一下可靠得多
|
||||||
|
$probeKey = 'avatars/.probe-' . bin2hex(random_bytes(6)) . '.txt';
|
||||||
|
$payload = 'familybook-probe-' . time();
|
||||||
|
$put = \App\Storage\Storage::putContents($familyId, $payload, $probeKey, 'text/plain');
|
||||||
|
if (empty($put['ok'])) {
|
||||||
|
flash('err', '写入失败:' . (string)($put['error'] ?? ''));
|
||||||
|
redirect('settings');
|
||||||
|
}
|
||||||
|
$get = \App\Storage\Storage::read($familyId, $probeKey);
|
||||||
|
$ok = !empty($get['ok']) && trim((string)$get['body']) === $payload;
|
||||||
|
\App\Storage\Storage::delete($familyId, $probeKey);
|
||||||
|
flash($ok ? 'ok' : 'err',
|
||||||
|
$ok ? '存储可用:写入 → 读回 → 删除 三步都通过了(驱动:' . $disk->id() . ')'
|
||||||
|
: '写入成功但读回来对不上,请检查桶权限或内网/外网 endpoint。');
|
||||||
|
redirect('settings');
|
||||||
|
break;
|
||||||
|
|
||||||
case 'settings/update':
|
case 'settings/update':
|
||||||
Auth::requireAdmin();
|
Auth::requireAdmin();
|
||||||
csrf_check();
|
csrf_check();
|
||||||
|
|||||||
+3
-3
@@ -50,9 +50,9 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
|||||||
|
|
||||||
if (!$errors) {
|
if (!$errors) {
|
||||||
try {
|
try {
|
||||||
foreach (Schema::statements() as $sql) {
|
// ensure() 而不是逐条 exec(statements()):它还会补齐后加的列与枚举值,
|
||||||
Database::get()->exec($sql);
|
// 所以老库重新跑一次就能自动升级,不必手工改表。
|
||||||
}
|
Schema::ensure();
|
||||||
$familyId = Family::create($familyName, $motto);
|
$familyId = Family::create($familyName, $motto);
|
||||||
$personId = Person::create($familyId, [
|
$personId = Person::create($familyId, [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
|
|||||||
+69
-24
@@ -1,19 +1,37 @@
|
|||||||
<?php
|
<?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">
|
<section class="hero">
|
||||||
<img class="hero-logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
|
<img class="hero-logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
|
||||||
<div>
|
<div>
|
||||||
<h1><?= e($family['name']) ?></h1>
|
<h1><?= e(\App\Auth::homeTitle()) ?></h1>
|
||||||
<p class="motto"><?= e($family['motto'] ?: '一本用岁月写成的家谱') ?></p>
|
<p class="motto">
|
||||||
|
<?php if ($me): ?>
|
||||||
|
<?= e($family['name']) ?> · 这里的称呼都是按你的视角算的
|
||||||
|
<?php else: ?>
|
||||||
|
<?= e($family['motto'] ?: '一本用岁月写成的家谱') ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-stats">
|
<div class="hero-stats">
|
||||||
<div class="stat"><b><?= (int)$memberCount ?></b><span>位家庭成员</span></div>
|
<div class="stat"><b><?= (int)$kinCount ?></b><span>与我有关</span></div>
|
||||||
<div class="stat"><b><?= (int)$openCount ?></b><span>已开通登录</span></div>
|
<div class="stat"><b><?= (int)$allCount ?></b><span>名册总数</span></div>
|
||||||
<div class="stat"><b><?= count($birthdays) ?></b><span>近期生日</span></div>
|
<div class="stat"><b><?= (int)$relCount ?></b><span>亲属连线</span></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -23,9 +41,12 @@ foreach ($people as $p) { if (!empty($p['account_id'])) { $openCount++; } }
|
|||||||
<div class="bdays">
|
<div class="bdays">
|
||||||
<?php foreach ($birthdays as $b): ?>
|
<?php foreach ($birthdays as $b): ?>
|
||||||
<div class="bday">
|
<div class="bday">
|
||||||
<img src="<?= e(\App\Upload::avatarUrl($b['avatar'])) ?>" alt="">
|
<img src="<?= e(avatar_url($b['avatar'])) ?>" alt="">
|
||||||
<div>
|
<div>
|
||||||
<b><?= e($b['name']) ?></b>
|
<b><?= e($b['name']) ?></b>
|
||||||
|
<?php if (!empty($b['label']) && $b['label'] !== '我'): ?>
|
||||||
|
<span class="badge"><?= e($b['label']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
<div class="d">
|
<div class="d">
|
||||||
<?php if ($b['days_left'] === 0): ?>
|
<?php if ($b['days_left'] === 0): ?>
|
||||||
就是今天,生日快乐
|
就是今天,生日快乐
|
||||||
@@ -42,36 +63,60 @@ foreach ($people as $p) { if (!empty($p['account_id'])) { $openCount++; } }
|
|||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="page-head" style="margin-bottom:12px">
|
<div class="page-head" style="margin-bottom:12px">
|
||||||
<h1 style="font-size:15px">家庭成员</h1>
|
<h1 style="font-size:15px"><?= $hasRel ? '我的家人' : '家庭成员' ?></h1>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn btn-ghost btn-sm" href="<?= e(url('people')) ?>">管理名册</a>
|
<a class="btn btn-ghost btn-sm" href="<?= e(url('kin')) ?>">我的家谱</a>
|
||||||
<?php if (\App\Auth::isAdmin()): ?>
|
<?php if ($me): ?>
|
||||||
<a class="btn btn-sm" href="<?= e(url('people/new')) ?>">添加成员</a>
|
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/show', ['id' => (int)$me['id']])) ?>">补充亲属关系</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="empty">
|
||||||
<b>名册还是空的</b>
|
<b>名册还是空的</b>
|
||||||
先把家里人加进来,之后他们就能出现在每一条记录里。
|
先把家里人加进来,之后他们就能出现在每一条记录里。
|
||||||
|
<div class="btn-row" style="justify-content:center">
|
||||||
|
<a class="btn" href="<?= e(url('people/new')) ?>">添加成员</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="people-grid">
|
<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']])) ?>">
|
<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="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">
|
<div class="pmeta">
|
||||||
<?php if ($p['account_role'] === 'admin' && $p['account_status'] === 'active'): ?>
|
<?php if (!empty($p['label'])): ?>
|
||||||
<span class="badge badge-admin">管理员</span>
|
<span class="rel-label"><?= e($p['label']) ?></span>
|
||||||
<?php elseif (!empty($p['account_id'])): ?>
|
|
||||||
<span class="badge badge-open">已开通</span>
|
|
||||||
<?php else: ?>
|
|
||||||
<span class="badge">未开通</span>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php
|
<?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'] ?? '');
|
$age = age_from($p['birthday'] ?? '');
|
||||||
if ($age !== null) { echo ' · ' . (int)$age . ' 岁'; }
|
if ($age !== null) { echo ' · ' . (int)$age . ' 岁'; }
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="auth-wrap">
|
<div class="auth-wrap">
|
||||||
<div class="auth-card">
|
<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>
|
<h1>安装「家书」</h1>
|
||||||
<p class="lead">先建好你的家庭空间,再给家里人开通知。填完这一步就能用了。</p>
|
<p class="lead">先建好你的家庭空间,再给家里人开通知。填完这一步就能用了。</p>
|
||||||
|
|
||||||
|
|||||||
@@ -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; ?>
|
||||||
@@ -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
@@ -1,12 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
$navItems = [
|
$navItems = [
|
||||||
'home' => ['首页', 'home'],
|
'home' => ['首页', 'home'],
|
||||||
|
'kin' => ['家谱', 'kin'],
|
||||||
'people' => ['名册', 'people'],
|
'people' => ['名册', 'people'],
|
||||||
];
|
];
|
||||||
if (\App\Auth::isAdmin()) {
|
if (\App\Auth::isAdmin()) {
|
||||||
$navItems['settings'] = ['设置', 'settings'];
|
$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>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
@@ -22,7 +28,7 @@ $me = \App\Auth::check();
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<?php if ($me): ?>
|
<?php if ($isLoggedIn): ?>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="topbar-inner">
|
<div class="topbar-inner">
|
||||||
<a class="brand" href="<?= e(url('home')) ?>">
|
<a class="brand" href="<?= e(url('home')) ?>">
|
||||||
@@ -36,8 +42,9 @@ $me = \App\Auth::check();
|
|||||||
</nav>
|
</nav>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
<span class="who">
|
<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>
|
<span class="who-name"><?= e(\App\Auth::name()) ?></span>
|
||||||
|
<?php if (\App\Auth::isGuest()): ?><span class="badge">访客</span><?php endif; ?>
|
||||||
</span>
|
</span>
|
||||||
<a class="link-quiet who-pwd" href="<?= e(url('auth/password')) ?>">改密码</a>
|
<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>
|
<a class="link-quiet" href="<?= e(url('auth/logout', ['_csrf' => csrf_token()])) ?>">退出</a>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<div class="auth-wrap">
|
<div class="auth-wrap">
|
||||||
<div class="auth-card">
|
<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>
|
<h1><?= e($familyName) ?></h1>
|
||||||
<p class="lead">用手机号登录你的家书。</p>
|
<p class="lead">用手机号登录你的家书。</p>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
$isEdit = !empty($person['id']);
|
$isEdit = !empty($person['id']);
|
||||||
$avatarUrl = \App\Upload::avatarUrl($person['avatar'] ?? null);
|
$avatarUrl = avatar_url($person['avatar'] ?? null);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="page-head">
|
<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; ?>
|
<?php if (!empty($errors['birthday'])): ?><span class="err"><?= e($errors['birthday']) ?></span><?php endif; ?>
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label for="phone">手机号</label>
|
<label for="phone">手机号</label>
|
||||||
<input class="input" id="phone" name="phone" inputmode="numeric"
|
<input class="input" id="phone" name="phone" inputmode="numeric"
|
||||||
@@ -103,6 +111,20 @@ $avatarUrl = \App\Upload::avatarUrl($person['avatar'] ?? null);
|
|||||||
<span class="tip">小的排前面。</span>
|
<span class="tip">小的排前面。</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
|
|||||||
@@ -26,17 +26,26 @@
|
|||||||
<div class="people-grid">
|
<div class="people-grid">
|
||||||
<?php foreach ($people as $p): ?>
|
<?php foreach ($people as $p): ?>
|
||||||
<a class="person" href="<?= e(url('people/show', ['id' => (int)$p['id']])) ?>">
|
<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="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">
|
<div class="pmeta">
|
||||||
<?php if ($p['account_role'] === 'admin' && $p['account_status'] === 'active'): ?>
|
<?php if (!empty($p['label']) && $p['label'] !== '我'): ?>
|
||||||
<span class="badge badge-admin">管理员</span>
|
<span class="rel-label"><?= e($p['label']) ?></span>
|
||||||
<?php elseif (!empty($p['account_id'])): ?>
|
|
||||||
<span class="badge badge-open">已开通</span>
|
|
||||||
<?php else: ?>
|
|
||||||
<span class="badge">未开通</span>
|
|
||||||
<?php endif; ?>
|
<?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; ?>
|
<?php if (!empty($p['nickname'])): ?> · <?= e($p['nickname']) ?><?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
@@ -1,21 +1,48 @@
|
|||||||
<?php
|
<?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">
|
<div class="page-head">
|
||||||
<h1><?= e($person['name']) ?></h1>
|
<h1><?= e($p['name']) ?></h1>
|
||||||
<span class="sub">
|
<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>
|
</span>
|
||||||
<div class="actions">
|
<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): ?>
|
<?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"
|
<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() ?>
|
<?= 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>
|
<button class="btn btn-danger btn-sm" type="submit">移除</button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -24,13 +51,15 @@ $admin = \App\Auth::isAdmin();
|
|||||||
|
|
||||||
<div class="detail">
|
<div class="detail">
|
||||||
<aside class="detail-photo">
|
<aside class="detail-photo">
|
||||||
<img src="<?= e(\App\Upload::avatarUrl($person['avatar'])) ?>" alt="">
|
<img src="<?= e(avatar_url($p['avatar'])) ?>" alt="">
|
||||||
<div class="name"><?= e($person['name']) ?></div>
|
<div class="name"><?= e($p['name']) ?></div>
|
||||||
<div class="sub">
|
<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'])): ?>
|
<?php elseif ($role === 'guest'): ?>
|
||||||
已开通登录<?= $person['account_status'] === 'disabled' ? '(已停用)' : '' ?>
|
访客(只读)
|
||||||
|
<?php elseif (!empty($p['account_id'])): ?>
|
||||||
|
已开通登录<?= $p['account_status'] === 'disabled' ? '(已停用)' : '' ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
还没有开通登录
|
还没有开通登录
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -40,23 +69,24 @@ $admin = \App\Auth::isAdmin();
|
|||||||
<div>
|
<div>
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<dl class="kv">
|
<dl class="kv">
|
||||||
<div class="kv-row">
|
<div class="kv-row"><dt>姓名</dt><dd><?= e($p['name']) ?></dd></div>
|
||||||
<dt>姓名</dt><dd><?= e($person['name']) ?></dd>
|
<?php if (!empty($p['nickname'])): ?>
|
||||||
</div>
|
<div class="kv-row"><dt>小名</dt><dd><?= e($p['nickname']) ?></dd></div>
|
||||||
<?php if (!empty($person['nickname'])): ?>
|
|
||||||
<div class="kv-row"><dt>小名</dt><dd><?= e($person['nickname']) ?></dd></div>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="kv-row">
|
<div class="kv-row">
|
||||||
<dt>性别</dt>
|
<dt>性别</dt>
|
||||||
<dd><?= $person['gender'] === 'm' ? '男' : ($person['gender'] === 'f' ? '女' : '未填写') ?></dd>
|
<dd><?= e(\App\Person::genderWord((string)($p['gender'] ?? '')) ?: '未填写') ?></dd>
|
||||||
</div>
|
</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">
|
<div class="kv-row">
|
||||||
<dt>生日</dt>
|
<dt>生日</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<?php if (!empty($person['birthday'])): ?>
|
<?php if (!empty($p['birthday'])): ?>
|
||||||
<?= e($person['birthday']) ?>
|
<?= e($p['birthday']) ?>
|
||||||
<?php if ($age !== null): ?> · <?= (int)$age ?> 岁<?php endif; ?>
|
<?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 if ($d === 0): ?> · <span style="color:var(--brand)">今天生日</span>
|
||||||
<?php elseif ($d !== null): ?> · 还有 <?= (int)$d ?> 天<?php endif; ?>
|
<?php elseif ($d !== null): ?> · 还有 <?= (int)$d ?> 天<?php endif; ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
@@ -66,39 +96,199 @@ $admin = \App\Auth::isAdmin();
|
|||||||
</div>
|
</div>
|
||||||
<div class="kv-row">
|
<div class="kv-row">
|
||||||
<dt>手机号</dt>
|
<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>
|
||||||
<div class="kv-row">
|
<div class="kv-row">
|
||||||
<dt>身份证号</dt>
|
<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>
|
</div>
|
||||||
<?php if (!empty($person['email'])): ?>
|
<?php if (!empty($p['email'])): ?>
|
||||||
<div class="kv-row"><dt>邮箱</dt><dd><?= e($person['email']) ?></dd></div>
|
<div class="kv-row"><dt>邮箱</dt><dd><?= e($p['email']) ?></dd></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (!empty($person['address'])): ?>
|
<?php if (!empty($p['address'])): ?>
|
||||||
<div class="kv-row"><dt>住址</dt><dd><?= e($person['address']) ?></dd></div>
|
<div class="kv-row"><dt>住址</dt><dd><?= e($p['address']) ?></dd></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (!empty($person['note'])): ?>
|
<?php if (!empty($p['note'])): ?>
|
||||||
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e($person['note'])) ?></dd></div>
|
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e($p['note'])) ?></dd></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="kv-row">
|
<div class="kv-row">
|
||||||
<dt>最近登录</dt>
|
<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>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</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): ?>
|
<?php if ($admin): ?>
|
||||||
<div class="card card-pad" style="margin-top:16px">
|
<div class="card card-pad" style="margin-top:16px">
|
||||||
<h2 class="section-title">登录账号</h2>
|
<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">
|
<p class="small muted" style="margin:0 0 14px">
|
||||||
开通后,<?= e($person['name']) ?> 就能用手机号和密码登录家书了。
|
开通后,<?= e($p['name']) ?> 就能用手机号和密码登录家书了。
|
||||||
</p>
|
</p>
|
||||||
<form method="post" action="<?= e(url('accounts/create')) ?>" class="form-grid">
|
<form method="post" action="<?= e(url('accounts/create')) ?>" class="form-grid">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="person_id" value="<?= (int)$person['id'] ?>">
|
<input type="hidden" name="person_id" value="<?= $pid ?>">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>登录密码</label>
|
<label>登录密码</label>
|
||||||
<input class="input" name="password" type="password" placeholder="至少 8 位,含字母和数字" required>
|
<input class="input" name="password" type="password" placeholder="至少 8 位,含字母和数字" required>
|
||||||
@@ -106,8 +296,9 @@ $admin = \App\Auth::isAdmin();
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>角色</label>
|
<label>角色</label>
|
||||||
<select class="select" name="role">
|
<select class="select" name="role">
|
||||||
<option value="member">普通成员</option>
|
<option value="member">家庭成员(可创建与编辑)</option>
|
||||||
<option value="admin">管理员</option>
|
<option value="admin">管理员(可改系统设置)</option>
|
||||||
|
<option value="guest">访客(只能查看)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
@@ -116,24 +307,28 @@ $admin = \App\Auth::isAdmin();
|
|||||||
</form>
|
</form>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<p class="small muted" style="margin:0 0 14px">
|
<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>
|
</p>
|
||||||
<div class="btn-row" style="margin-top:0;flex-wrap:wrap">
|
<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">
|
<form method="post" action="<?= e(url('accounts/role')) ?>" style="display:inline">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="person_id" value="<?= (int)$person['id'] ?>">
|
<input type="hidden" name="person_id" value="<?= $pid ?>">
|
||||||
<input type="hidden" name="role" value="<?= $person['account_role'] === 'admin' ? 'member' : 'admin' ?>">
|
<select class="select" name="role" style="width:auto;display:inline-block">
|
||||||
<button class="btn btn-ghost btn-sm" type="submit">
|
<option value="member" <?= $role === 'member' ? 'selected' : '' ?>>家庭成员</option>
|
||||||
<?= $person['account_role'] === 'admin' ? '取消管理员' : '设为管理员' ?>
|
<option value="admin" <?= $role === 'admin' ? 'selected' : '' ?>>管理员</option>
|
||||||
</button>
|
<option value="guest" <?= $role === 'guest' ? 'selected' : '' ?>>访客</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">改角色</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="<?= e(url('accounts/status')) ?>" style="display:inline">
|
<form method="post" action="<?= e(url('accounts/status')) ?>" style="display:inline">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="person_id" value="<?= (int)$person['id'] ?>">
|
<input type="hidden" name="person_id" value="<?= $pid ?>">
|
||||||
<input type="hidden" name="status" value="<?= $person['account_status'] === 'active' ? 'disabled' : 'active' ?>">
|
<input type="hidden" name="status" value="<?= $p['account_status'] === 'active' ? 'disabled' : 'active' ?>">
|
||||||
<button class="btn btn-ghost btn-sm" type="submit">
|
<button class="btn btn-ghost btn-sm" type="submit">
|
||||||
<?= $person['account_status'] === 'active' ? '停用账号' : '启用账号' ?>
|
<?= $p['account_status'] === 'active' ? '停用账号' : '启用账号' ?>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
@@ -145,3 +340,26 @@ $admin = \App\Auth::isAdmin();
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<form method="post" action="<?= e(url('settings/update')) ?>" class="card card-pad">
|
<form method="post" action="<?= e(url('settings/update')) ?>" class="card card-pad">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
<h2 class="section-title">家庭</h2>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label for="name">家庭名称</label>
|
<label for="name">家庭名称</label>
|
||||||
@@ -16,15 +17,152 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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">
|
<div class="card card-pad" style="margin-top:20px">
|
||||||
<h2 class="section-title">运行环境 <span class="hint">排错时看这一块</span></h2>
|
<h2 class="section-title">运行环境 <span class="hint">排错时看这一块</span></h2>
|
||||||
<dl class="kv">
|
<dl class="kv">
|
||||||
<div class="kv-row"><dt>PHP</dt><dd><?= e(PHP_VERSION) ?></dd></div>
|
<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><?= 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><?= $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>
|
</dl>
|
||||||
<p class="small muted" style="margin:14px 0 0">
|
<p class="small muted" style="margin:14px 0 0">
|
||||||
手机号与身份证号都是加密后入库的。这个密钥一旦换掉,已有数据就再也解不开 ——
|
手机号与身份证号都是加密后入库的。这个密钥一旦换掉,已有数据就再也解不开 ——
|
||||||
请把它和数据库分开、各备份一份。
|
请把它和数据库分开、各备份一份。
|
||||||
</p>
|
</p>
|
||||||
|
<p class="small muted" style="margin:10px 0 0">
|
||||||
|
访客账号在「<a href="<?= e(url('accounts/invite')) ?>">邀请访客</a>」里开通。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
Reference in New Issue
Block a user