按九点反馈一次性改造: 1. 登录页「图标 + 用手机号登录你的家书」包进 .auth-head 并真正居中 (以前只加了个 class 名,没有样式,看着像做了其实没做)。 2. slogan 改成两段式:系统级 slogan 作兜底,每个人可在自己的资料里写一个, 填了就显示自己的、留空就回落到系统那句。settings 里「家庭名称」字段去掉 (只在安装时归档,不参与展示)。 3. 三个生日:身份证生日(从证件号截取,永不改动)/真实阳历/农历。 同一人填了其一即自动推出另一个(靠 birthday_source 指明填的是哪一侧, 不依赖 JS)。三者落在同一天时合并成一条提醒,否则分别提醒。 农历表不是照抄流传的那份 lunarInfo(它在 5 个年份上是错的)—— 用五个独立实现按 7.3 万天多数表决重新推导,再要求 100% 重现。 4. 称呼改成「视角属性」:nicknames(family_id, viewer_id, target_id, nickname)。 「小明」不是某个人的属性——同一个人被不同人叫法不同,所以是「谁看」的属性。 旧的 persons.nickname 由 Nickname::migrateLegacy() 搬到新表,只搬一次。 5. 点自己的头像/名字直接进「我的资料」,不必再绕 「名册 → 找到自己 → 编辑资料」三步。 6. 附件(头像与其它上传)一律走 media.php 代理,不再直连 uploads/ 或云端地址: 可读性由登录态决定而不是「知道文件名」,Content-Type/缓存/下载头由服务端统一给。 uploads/ 整个目录 deny(含历史遗留备份文件),并有真实探针验证。 7. OSS 设置页加「连通性测试」(本地 / 云端两个按钮),逐步给出结论与耗时。 8. 生日输入改用「原生日期框 + 24 小时制时:分:秒 三个下拉」: datetime-local 的格式由浏览器 UI 语言决定(中文环境渲染成「下午 03:30」, 没有秒、还容易把上下午填反)。点输入框任意一处都能弹出选择器(showPicker)。 9. 结构版本号收敛成 Schema::VERSION 一处,安装时即盖章,避免首次请求白跑升级。 验证(全部为「先注入 bug 证伪、再跑通过」): php -l 42 个文件;e2e 149 条;老库升级专测 37 条;反向验证 23 个注入全被抓; 桌面几何 108 条(768/1280/1920/2560);移动端真机几何 212 条(360/375/390/414); 交互检查 49 条(生日整框可点、登录居中)。
887 lines
37 KiB
PHP
887 lines
37 KiB
PHP
<?php
|
||
/**
|
||
* 家书 FamilyBook · 前端控制器
|
||
*
|
||
* 路由走 query-string(index.php?r=模块/动作),不依赖 Apache 重写,
|
||
* 换服务器、换 Nginx 都不用改配置。
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
require __DIR__ . '/app/bootstrap.php';
|
||
|
||
use App\Account;
|
||
use App\Auth;
|
||
use App\Birthday;
|
||
use App\Crypto;
|
||
use App\Database;
|
||
use App\Family;
|
||
use App\Kinship;
|
||
use App\Nickname;
|
||
use App\Person;
|
||
use App\Relations;
|
||
use App\Schema;
|
||
use App\Setting;
|
||
use App\Upload;
|
||
use App\View;
|
||
|
||
if (!Database::hasTables()) {
|
||
header('Location: install.php');
|
||
exit;
|
||
}
|
||
|
||
/**
|
||
* 一次性结构升级。
|
||
*
|
||
* ⚠️ 为什么要有这段:上线后的库不认识新加的列(birth_rank / is_placeholder)和
|
||
* 新的 guest 角色。靠人去手动 ALTER 迟早会漏,所以在每次请求的开头比一下版本号,
|
||
* 落后了就补一次 —— 平时只是一次字符串比较,几乎零成本。
|
||
*/
|
||
const FB_SCHEMA_VERSION = Schema::VERSION;
|
||
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 = $r === '' ? 'home' : $r;
|
||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||
|
||
$publicRoutes = ['auth/login'];
|
||
if (!Auth::check() && !in_array($r, $publicRoutes, true)) {
|
||
redirect('auth/login');
|
||
}
|
||
|
||
/**
|
||
* 从表单里读「三个生日」。
|
||
*
|
||
* 为什么要有 `birthday_source` 这个显式字段:
|
||
* 阳历生日与农历生日说的是**同一个人的同一天**,不能各填各的(会自相矛盾)。
|
||
* 用户手填时必然是在其中一边操作,所以让他明确"我这次填的是哪一个",
|
||
* 服务端据此**反推另一个** —— 不依赖 JS,也就不会有"JS 没跑到就静默存错"。
|
||
* 兼容:source=solar 但阳历留空、农历有值 → 按农历算(用户只填了一边)。
|
||
*/
|
||
function read_birthday_input(array $existing = []): array
|
||
{
|
||
$source = (($_POST['birthday_source'] ?? 'solar') === 'lunar') ? 'lunar' : 'solar';
|
||
$solar = (string)(read_dt_input('birthday_solar') ?? '');
|
||
$lunar = (string)(read_dt_input('birthday_lunar') ?? '');
|
||
if ($source === 'solar' && $solar === '' && $lunar !== '') {
|
||
$source = 'lunar';
|
||
}
|
||
return [
|
||
'birthday_source' => $source,
|
||
'birthday_solar' => $solar,
|
||
'birthday_lunar' => $lunar,
|
||
'birthday_lunar_leap' => !empty($_POST['birthday_lunar_leap']) ? 1 : 0,
|
||
// 库里已有的身份证生日要**带回来**:它是"只进不出"的,不能被一次提交清掉
|
||
'birthday_id_card' => (string)($existing['birthday_id_card'] ?? ''),
|
||
];
|
||
}
|
||
|
||
switch ($r) {
|
||
|
||
// ==================== 登录 / 退出 ====================
|
||
case 'auth/login':
|
||
if (Auth::check()) {
|
||
redirect('home');
|
||
}
|
||
if ($method === 'POST') {
|
||
csrf_check();
|
||
$phone = trim((string)($_POST['phone'] ?? ''));
|
||
$pass = (string)($_POST['password'] ?? '');
|
||
if (Auth::login($phone, $pass)) {
|
||
redirect('home');
|
||
}
|
||
View::renderBare('login', [
|
||
'title' => '登录',
|
||
'familyName' => fb_config('site_name', '家书'),
|
||
'error' => '手机号或密码不对,或者账号还没开通/已被停用。',
|
||
'phone' => $phone,
|
||
]);
|
||
exit;
|
||
}
|
||
View::renderBare('login', [
|
||
'title' => '登录',
|
||
'familyName' => fb_config('site_name', '家书'),
|
||
]);
|
||
break;
|
||
|
||
case 'auth/logout':
|
||
if (isset($_GET['_csrf'], $_SESSION['_csrf'])
|
||
&& hash_equals($_SESSION['_csrf'], (string)$_GET['_csrf'])) {
|
||
Auth::logout();
|
||
}
|
||
redirect('auth/login');
|
||
break;
|
||
|
||
case 'auth/password':
|
||
Auth::requireLogin();
|
||
if ($method === 'POST') {
|
||
csrf_check();
|
||
$old = (string)($_POST['old_password'] ?? '');
|
||
$new = (string)($_POST['password'] ?? '');
|
||
$new2 = (string)($_POST['password2'] ?? '');
|
||
|
||
$st = Database::get()->prepare('SELECT password_hash FROM accounts WHERE id = ?');
|
||
$st->execute([Auth::id()]);
|
||
$hash = (string)$st->fetchColumn();
|
||
|
||
$err = null;
|
||
if (!password_verify($old, $hash)) {
|
||
$err = '当前密码不对';
|
||
} elseif ($new !== $new2) {
|
||
$err = '两次输入的新密码不一致';
|
||
} elseif (Account::validatePassword($new)) {
|
||
$err = implode(';', Account::validatePassword($new));
|
||
}
|
||
if ($err) {
|
||
View::render('auth/password', ['title' => '修改密码', 'error' => $err], 'home');
|
||
exit;
|
||
}
|
||
Account::setPassword((int)Auth::id(), $new);
|
||
flash('ok', '密码已更新,下次登录用新密码。');
|
||
redirect('home');
|
||
}
|
||
View::render('auth/password', ['title' => '修改密码'], 'home');
|
||
break;
|
||
|
||
// ==================== 首页(以我为中心) ====================
|
||
case 'home':
|
||
$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) : '';
|
||
// 名字优先用"我叫他什么" —— 同一个孩子,爸爸看到"儿子"、奶奶看到"小宝"
|
||
$b['shown'] = Nickname::display($me, $people[(int)$b['id']] ?? null) ?: $b['name'];
|
||
}
|
||
unset($b);
|
||
|
||
View::render('home', [
|
||
'title' => '首页',
|
||
'family' => Family::find($familyId),
|
||
'me' => $people[$me] ?? null,
|
||
'meId' => $me,
|
||
'kin' => $kin,
|
||
'allCount' => count($people),
|
||
'relCount' => Relations::count($familyId),
|
||
'birthdays' => $birthdays,
|
||
// 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页)
|
||
'mySlogan' => trim((string)($people[$me]['slogan'] ?? '')),
|
||
'myNicknames'=> Nickname::mapFor($me),
|
||
], 'home');
|
||
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':
|
||
$q = trim((string)($_GET['q'] ?? ''));
|
||
$familyId = (int)Auth::familyId();
|
||
$me = (int)Auth::personId();
|
||
$rows = Person::all($familyId, $q, 'member', $me);
|
||
$people = Person::map($familyId);
|
||
$graph = Kinship::graph($familyId);
|
||
// 名册里也标出「我该怎么称呼他」
|
||
foreach ($rows as &$row) {
|
||
$row['label'] = Kinship::label($graph, $me, (int)$row['id'], $people);
|
||
$row['shown'] = Nickname::display($me, $row) ?: (string)$row['name'];
|
||
}
|
||
unset($row);
|
||
|
||
View::render('people/index', [
|
||
'title' => '家庭名册',
|
||
'people' => $rows,
|
||
'q' => $q,
|
||
'meId' => $me,
|
||
], 'people');
|
||
break;
|
||
|
||
// ==================== 我自己的资料 ====================
|
||
// 点顶栏的头像/名字直接落到这里。访客也能改自己的资料 ——
|
||
// 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。
|
||
case 'people/me':
|
||
Auth::requireLogin();
|
||
$me = (int)Auth::personId();
|
||
$p = Person::find($me);
|
||
if (!$p) {
|
||
flash('err', '找不到你自己的名册记录。');
|
||
redirect('home');
|
||
}
|
||
clear_old();
|
||
View::render('people/me', [
|
||
'title' => '我的资料',
|
||
'person' => $p,
|
||
'errors' => [],
|
||
'familyMotto'=> (string)(Family::find((int)Auth::familyId())['motto'] ?? ''),
|
||
'nicknames' => Nickname::mineWithNames((int)Auth::familyId(), $me),
|
||
'options' => Person::options((int)Auth::familyId(), $me, $me),
|
||
'birthdays' => Birthday::describe($p),
|
||
], 'people');
|
||
break;
|
||
|
||
case 'people/me_save':
|
||
Auth::requireLogin();
|
||
csrf_check();
|
||
$me = (int)Auth::personId();
|
||
$p = Person::find($me);
|
||
if (!$p) {
|
||
flash('err', '找不到你自己的名册记录。');
|
||
redirect('home');
|
||
}
|
||
$d = [
|
||
'name' => trim((string)($_POST['name'] ?? '')),
|
||
'gender' => (string)($_POST['gender'] ?? ''),
|
||
'email' => trim((string)($_POST['email'] ?? '')),
|
||
'address' => trim((string)($_POST['address'] ?? '')),
|
||
'note' => trim((string)($_POST['note'] ?? '')),
|
||
'slogan' => trim((string)($_POST['slogan'] ?? '')),
|
||
'avatar' => (string)$p['avatar'],
|
||
] + read_birthday_input($p);
|
||
|
||
$errors = Person::validate($d);
|
||
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []);
|
||
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
|
||
$errors['avatar'] = $up['error'];
|
||
}
|
||
if ($errors) {
|
||
set_old($_POST);
|
||
View::render('people/me', [
|
||
'title' => '我的资料',
|
||
'person' => $p,
|
||
'errors' => $errors,
|
||
'familyMotto'=> (string)(Family::find((int)Auth::familyId())['motto'] ?? ''),
|
||
'nicknames' => Nickname::mineWithNames((int)Auth::familyId(), $me),
|
||
'options' => Person::options((int)Auth::familyId(), $me, $me),
|
||
'birthdays' => Birthday::describe($p),
|
||
], 'people');
|
||
exit;
|
||
}
|
||
if ($up['ok']) {
|
||
Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
|
||
$d['avatar'] = $up['file'];
|
||
}
|
||
$warn = Birthday::normalize($d)['warning'];
|
||
Person::updateSelf($me, $d);
|
||
if ($warn !== '') {
|
||
flash('err', $warn);
|
||
}
|
||
// 姓名/头像改了要同步会话,否则顶栏还是旧的
|
||
$_SESSION['name'] = $d['name'];
|
||
Auth::refreshAvatar((string)$d['avatar']);
|
||
clear_old();
|
||
flash('ok', '已保存。');
|
||
redirect('people/me');
|
||
break;
|
||
|
||
// ==================== 别人怎么称呼(只有我自己能定) ====================
|
||
case 'nickname/save':
|
||
Auth::requireLogin();
|
||
csrf_check();
|
||
$familyId = (int)Auth::familyId();
|
||
$me = (int)Auth::personId();
|
||
$target = (int)($_POST['target_id'] ?? 0);
|
||
$nick = (string)($_POST['nickname'] ?? '');
|
||
$t = Person::find($target);
|
||
$back = (string)($_POST['back'] ?? 'people/show');
|
||
if (!$t || (int)$t['family_id'] !== $familyId) {
|
||
flash('err', '找不到这个人。');
|
||
redirect('people');
|
||
}
|
||
Nickname::set($familyId, $me, $target, $nick);
|
||
flash('ok', trim($nick) === ''
|
||
? '已恢复成用姓名显示。'
|
||
: '记下了:你叫他「' . trim($nick) . '」。这只影响你自己看到的称呼。');
|
||
if ($back === 'people/me') {
|
||
redirect('people/me');
|
||
}
|
||
redirect('people/show', ['id' => $target]);
|
||
break;
|
||
|
||
case 'people/new':
|
||
Auth::requireWrite();
|
||
clear_old();
|
||
View::render('people/form', [
|
||
'title' => '添加成员',
|
||
'person' => null,
|
||
'errors' => [],
|
||
], 'people');
|
||
break;
|
||
|
||
case 'people/create':
|
||
Auth::requireWrite();
|
||
csrf_check();
|
||
$d = [
|
||
'name' => trim((string)($_POST['name'] ?? '')),
|
||
'gender' => (string)($_POST['gender'] ?? ''),
|
||
'phone' => trim((string)($_POST['phone'] ?? '')),
|
||
'id_card' => trim((string)($_POST['id_card'] ?? '')),
|
||
'email' => trim((string)($_POST['email'] ?? '')),
|
||
'address' => trim((string)($_POST['address'] ?? '')),
|
||
'note' => trim((string)($_POST['note'] ?? '')),
|
||
'slogan' => trim((string)($_POST['slogan'] ?? '')),
|
||
'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
|
||
'avatar' => '',
|
||
'kind' => 'member',
|
||
// ⚠️ 这两项必须显式从表单取回:表单里有,但漏了这一步就会被静默丢掉,
|
||
// 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。
|
||
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')),
|
||
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
|
||
] + read_birthday_input();
|
||
$errors = Person::validate($d);
|
||
|
||
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []);
|
||
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
|
||
$errors['avatar'] = $up['error'];
|
||
}
|
||
if ($errors) {
|
||
set_old($_POST);
|
||
View::render('people/form', [
|
||
'title' => '添加成员',
|
||
'person' => null,
|
||
'errors' => $errors,
|
||
'phoneValue' => $d['phone'],
|
||
'idCardValue'=> $d['id_card'],
|
||
], 'people');
|
||
exit;
|
||
}
|
||
if ($up['ok']) {
|
||
$d['avatar'] = $up['file'];
|
||
}
|
||
try {
|
||
$id = Person::create((int)Auth::familyId(), $d);
|
||
} catch (\PDOException $ex) {
|
||
set_old($_POST);
|
||
View::render('people/form', [
|
||
'title' => '添加成员',
|
||
'person' => null,
|
||
'errors' => ['phone' => '这个手机号已经在名册里了'],
|
||
'phoneValue' => $d['phone'],
|
||
'idCardValue'=> $d['id_card'],
|
||
], 'people');
|
||
exit;
|
||
}
|
||
clear_old();
|
||
flash('ok', '已把「' . $d['name'] . '」加进名册。');
|
||
redirect('people/show', ['id' => $id]);
|
||
break;
|
||
|
||
case 'people/edit':
|
||
Auth::requireWrite();
|
||
$p = Person::find((int)($_GET['id'] ?? 0));
|
||
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
|
||
flash('err', '找不到这个人。');
|
||
redirect('people');
|
||
}
|
||
clear_old();
|
||
View::render('people/form', [
|
||
'title' => '编辑成员',
|
||
'person' => $p,
|
||
'errors' => [],
|
||
'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']),
|
||
'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']),
|
||
], 'people');
|
||
break;
|
||
|
||
case 'people/update':
|
||
Auth::requireWrite();
|
||
csrf_check();
|
||
$id = (int)($_POST['id'] ?? 0);
|
||
$p = Person::find($id);
|
||
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
|
||
flash('err', '找不到这个人。');
|
||
redirect('people');
|
||
}
|
||
$d = [
|
||
'name' => trim((string)($_POST['name'] ?? '')),
|
||
'gender' => (string)($_POST['gender'] ?? ''),
|
||
'phone' => trim((string)($_POST['phone'] ?? '')),
|
||
'id_card' => trim((string)($_POST['id_card'] ?? '')),
|
||
'email' => trim((string)($_POST['email'] ?? '')),
|
||
'address' => trim((string)($_POST['address'] ?? '')),
|
||
'note' => trim((string)($_POST['note'] ?? '')),
|
||
'slogan' => trim((string)($_POST['slogan'] ?? '')),
|
||
'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
|
||
'avatar' => (string)$p['avatar'],
|
||
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')),
|
||
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
|
||
] + read_birthday_input($p);
|
||
$errors = Person::validate($d);
|
||
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []);
|
||
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
|
||
$errors['avatar'] = $up['error'];
|
||
}
|
||
if ($errors) {
|
||
set_old($_POST);
|
||
View::render('people/form', [
|
||
'title' => '编辑成员',
|
||
'person' => $p,
|
||
'errors' => $errors,
|
||
'phoneValue' => $d['phone'],
|
||
'idCardValue' => $d['id_card'],
|
||
], 'people');
|
||
exit;
|
||
}
|
||
if ($up['ok']) {
|
||
Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
|
||
$d['avatar'] = $up['file'];
|
||
}
|
||
try {
|
||
Person::update($id, $d);
|
||
} catch (\PDOException $ex) {
|
||
set_old($_POST);
|
||
View::render('people/form', [
|
||
'title' => '编辑成员',
|
||
'person' => $p,
|
||
'errors' => ['phone' => '这个手机号已经被名册里的另一个人用了'],
|
||
'phoneValue' => $d['phone'],
|
||
'idCardValue' => $d['id_card'],
|
||
], 'people');
|
||
exit;
|
||
}
|
||
if ((int)$p['id'] === (int)Auth::personId()) {
|
||
Auth::refreshAvatar((string)$d['avatar']);
|
||
}
|
||
clear_old();
|
||
flash('ok', '已保存。');
|
||
redirect('people/show', ['id' => $id]);
|
||
break;
|
||
|
||
case 'people/show':
|
||
$familyId = (int)Auth::familyId();
|
||
$me = (int)Auth::personId();
|
||
$id = (int)($_GET['id'] ?? 0);
|
||
$p = Person::findWithAccount($id, $me);
|
||
if (!$p || (int)$p['family_id'] !== $familyId) {
|
||
flash('err', '找不到这个人。');
|
||
redirect('people');
|
||
}
|
||
$people = Person::map($familyId);
|
||
$graph = Kinship::graph($familyId);
|
||
|
||
// 这个人在「我」眼里是谁,以及我与他之间的直接关系(可编辑的那些)
|
||
$tree = Kinship::tree($graph, $id, $people, 2, 2);
|
||
|
||
View::render('people/show', [
|
||
'title' => $p['name'],
|
||
'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, $me),
|
||
'kinReady' => Kinship::graph($familyId) !== [],
|
||
// 「我叫他什么」—— 只有我自己能改,改了也只影响我看到的
|
||
'myNickname' => $id === $me ? '' : Nickname::get($me, $id),
|
||
'shown' => $id === $me ? (string)$p['name'] : (Nickname::display($me, $p) ?: (string)$p['name']),
|
||
'birthdays' => Birthday::describe($p),
|
||
'birthdayNext'=> Birthday::nextOccurrences($p, date('Y-m-d'), 400),
|
||
], 'people');
|
||
break;
|
||
|
||
case 'people/destroy':
|
||
Auth::requireAdmin();
|
||
csrf_check();
|
||
$id = (int)($_POST['id'] ?? 0);
|
||
$p = Person::find($id);
|
||
if ($p && (int)$p['family_id'] === (int)Auth::familyId()) {
|
||
if ((int)$p['id'] === (int)Auth::personId()) {
|
||
flash('err', '不能把自己从名册里移除。');
|
||
redirect('people/show', ['id' => $id]);
|
||
}
|
||
// 不让最后一个管理员消失:系统里没人能管理家庭就锁死了
|
||
$acc = Account::findByPerson($id);
|
||
if ($acc && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
|
||
flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。');
|
||
redirect('people/show', ['id' => $id]);
|
||
}
|
||
Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
|
||
Person::delete($id);
|
||
flash('ok', '已从名册移除「' . $p['name'] . '」。');
|
||
}
|
||
redirect('people');
|
||
break;
|
||
|
||
// ==================== 账号 ====================
|
||
case 'accounts/create':
|
||
Auth::requireAdmin();
|
||
csrf_check();
|
||
$personId = (int)($_POST['person_id'] ?? 0);
|
||
$p = Person::find($personId);
|
||
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
|
||
flash('err', '找不到这个人。');
|
||
redirect('people');
|
||
}
|
||
if (Account::findByPerson($personId)) {
|
||
flash('err', '这个人的账号已经开通过了。');
|
||
redirect('people/show', ['id' => $personId]);
|
||
}
|
||
if (Crypto::decrypt((string)$p['phone_encrypted']) === '') {
|
||
flash('err', '要先填手机号,才能开通登录。');
|
||
redirect('people/show', ['id' => $personId]);
|
||
}
|
||
$pass = (string)($_POST['password'] ?? '');
|
||
$errs = Account::validatePassword($pass);
|
||
if ($errs) {
|
||
flash('err', implode(';', $errs));
|
||
redirect('people/show', ['id' => $personId]);
|
||
}
|
||
$r0 = (string)($_POST['role'] ?? '');
|
||
Account::create($personId, $pass, in_array($r0, Account::ROLES, true) ? $r0 : 'member');
|
||
flash('ok', '账号已开通,用手机号就能登录。');
|
||
redirect('people/show', ['id' => $personId]);
|
||
break;
|
||
|
||
case 'accounts/role':
|
||
Auth::requireAdmin();
|
||
csrf_check();
|
||
$personId = (int)($_POST['person_id'] ?? 0);
|
||
$role = in_array(($r1 = (string)($_POST['role'] ?? '')), Account::ROLES, true) ? $r1 : 'member';
|
||
$p = Person::find($personId);
|
||
$acc = Account::findByPerson($personId);
|
||
if ($p && $acc) {
|
||
if ($role === 'member' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
|
||
flash('err', '至少要留一位管理员。');
|
||
redirect('people/show', ['id' => $personId]);
|
||
}
|
||
Account::setRole((int)$acc['id'], $role);
|
||
flash('ok', '权限已更新。');
|
||
}
|
||
redirect('people/show', ['id' => $personId]);
|
||
break;
|
||
|
||
case 'accounts/status':
|
||
Auth::requireAdmin();
|
||
csrf_check();
|
||
$personId = (int)($_POST['person_id'] ?? 0);
|
||
$status = ($_POST['status'] ?? '') === 'active' ? 'active' : 'disabled';
|
||
$acc = Account::findByPerson($personId);
|
||
if ($acc) {
|
||
if ((int)$acc['person_id'] === (int)Auth::personId()) {
|
||
flash('err', '不能停用自己。');
|
||
redirect('people/show', ['id' => $personId]);
|
||
}
|
||
if ($status === 'disabled' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
|
||
flash('err', '至少要留一位管理员。');
|
||
redirect('people/show', ['id' => $personId]);
|
||
}
|
||
Account::setStatus((int)$acc['id'], $status);
|
||
flash('ok', $status === 'active' ? '账号已启用。' : '账号已停用。');
|
||
}
|
||
redirect('people/show', ['id' => $personId]);
|
||
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();
|
||
// ⚠️ 访客建出来是 kind='contact'(不进家谱),而 Person::all() 默认只取 'member' ——
|
||
// 早先这里没传 kind,于是"已有访客"永远列不出人来(页面看着像是从没开通过)。
|
||
View::render('people/invite', [
|
||
'title' => '邀请访客',
|
||
'people' => Person::all((int)Auth::familyId(), '', 'contact', (int)Auth::personId()),
|
||
'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, 'gender' => '', 'avatar' => '', 'phone' => $phone,
|
||
'id_card' => '', 'email' => '', 'address' => '', 'note' => $note,
|
||
'slogan' => '', '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, '', 'contact', (int)Auth::personId()),
|
||
'options' => Person::options($familyId),
|
||
'errors' => $errors,
|
||
], 'people');
|
||
break;
|
||
|
||
// ==================== 设置 ====================
|
||
case 'settings':
|
||
Auth::requireAdmin();
|
||
$dbVersion = '未知';
|
||
try {
|
||
$dbVersion = Database::get()->query('SELECT VERSION()')->fetchColumn();
|
||
} catch (\Throwable $ex) {
|
||
// 拿不到版本不影响页面
|
||
}
|
||
$rawKey = (string)fb_config('app_key', '');
|
||
$acc = Account::roleCounts();
|
||
// 诊断结果由 settings/test_oss 写进会话,这里取出即用即清(POST → 302 → GET)
|
||
$diag = $_SESSION['_oss_diag'] ?? null;
|
||
unset($_SESSION['_oss_diag']);
|
||
View::render('settings', [
|
||
'title' => '设置',
|
||
'family' => Family::find((int)Auth::familyId()),
|
||
'dbVersion' => $dbVersion,
|
||
'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()),
|
||
'ossDiag' => $diag,
|
||
], 'settings');
|
||
break;
|
||
|
||
/**
|
||
* 阿里云 OSS 连通性测试。
|
||
*
|
||
* 与 settings/test_storage 的分工:
|
||
* · test_storage 测的是**当前生效的驱动**(可能是本地),判"能不能存东西";
|
||
* · 这一个测的是**已填的 OSS 配置本身**,即使当前跑在本地也能测 ——
|
||
* 这样就能"先把 OSS 测通、再切过去",而不是"先切、发现不行、再切回来"。
|
||
* 它会把每一步的结论摊开(配置/凭据/地域/公网/内网/读写),
|
||
* 让"连不上"这件事有具体原因,而不是一句"写入失败"。
|
||
*/
|
||
case 'settings/test_oss':
|
||
Auth::requireAdmin();
|
||
csrf_check();
|
||
$familyId = (int)Auth::familyId();
|
||
$disk = \App\Storage\Storage::ossDiskForDiagnosis($familyId);
|
||
if ($disk === null) {
|
||
$_SESSION['_oss_diag'] = [
|
||
'ok' => false,
|
||
'steps' => [[
|
||
'name' => '配置完整性', 'ok' => false, 'ms' => 0,
|
||
'detail' => 'OSS 参数还没填全(至少要 Bucket、Endpoint 和一种凭据)。先在下面填好并保存,再测。',
|
||
]],
|
||
'at' => time(),
|
||
];
|
||
redirect('settings');
|
||
}
|
||
$steps = $disk->diagnose();
|
||
$ok = true;
|
||
foreach ($steps as $s) {
|
||
if (empty($s['ok'])) {
|
||
$ok = false;
|
||
break;
|
||
}
|
||
}
|
||
$_SESSION['_oss_diag'] = ['ok' => $ok, 'steps' => $steps, 'at' => time()];
|
||
redirect('settings');
|
||
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':
|
||
Auth::requireAdmin();
|
||
csrf_check();
|
||
$fid = (int)Auth::familyId();
|
||
// ⚠️ 「家庭名称」已从设置里撤掉(见 settings 模板的说明):
|
||
// 首页标题现在是「某某某的家书」,一个家庭级名字既没人看、也容易和它打架。
|
||
// 这里沿用已有值,不再接受表单输入 —— 免得"某次提交把名字清空"。
|
||
$cur = Family::find($fid);
|
||
Family::update(
|
||
$fid,
|
||
(string)($cur['name'] ?? '家书'),
|
||
trim((string)($_POST['motto'] ?? ''))
|
||
);
|
||
flash('ok', '已保存。');
|
||
redirect('settings');
|
||
break;
|
||
|
||
default:
|
||
http_response_code(404);
|
||
View::render('error', [
|
||
'title' => '页面不存在',
|
||
'message' => '找不到这一页。',
|
||
], 'home');
|
||
break;
|
||
}
|