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'] ?? ''), ]; } /** * 渲染「个人设置」页(settings/me)。 * * 抽成函数是因为它有三处调用者:正常打开、以及资料表单校验失败时的两处 * ("我的资料"和"改密码"各一次)—— 那三处的数据装配必须完全一致, * 否则校验失败重画的那次会缺东西(比如分组顺序整块消失),且不会有任何报错。 * * @param array $person 当前登录者自己的名册记录 * @param array $errors 字段名 => 错误文案 */ function render_me_settings(array $person, array $errors = []): void { $familyId = (int)Auth::familyId(); $me = (int)$person['id']; View::render('settings/me', [ 'title' => '个人设置', 'person' => $person, 'errors' => $errors, 'familyMotto' => (string)(Family::find($familyId)['motto'] ?? ''), // 我怎么称呼别人(视角属性,只有我自己能定) 'nicknames' => Nickname::mineWithNames($familyId, $me), 'options' => Person::options($familyId, $me, $me), 'birthdays' => Birthday::describe($person), // 名册分组:顺序是「我的」,分类是「全家的」 'groups' => PersonGroup::orderFor($familyId, $me), 'groupCounts' => PersonGroup::counts($familyId, 'member'), ], 'settings'); } 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); // 按关系分组分栏。顺序用**当前用户自己的**(没自定义过就是默认顺序); // 归到键 0 的那一堆是还没分类的人,模板单独放在最后一栏。 $grouped = []; foreach ($rows as $row) { $grouped[(int)($row['group_id'] ?? 0)][] = $row; } View::render('people/index', [ 'title' => '家庭名册', 'people' => $rows, 'grouped'=> $grouped, 'groups' => PersonGroup::orderFor($familyId, $me), 'q' => $q, 'meId' => $me, ], 'people'); break; /** * 名册卡片上的「调整分组」。 * * 单开一个路由而不是让人去「编辑成员」里改,是因为那个表单很长 * (三个生日、身份证号、住址…),为了挪一个分组去翻半页是折磨。 * 只改 group_id 一列,所以走 Person::setGroup(),不会碰到别的字段。 */ case 'people/group': $familyId = (int)Auth::familyId(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能改名册里的分组。'); redirect('people'); } csrf_check(); $pid = (int)($_POST['person_id'] ?? 0); $gid = (int)($_POST['group_id'] ?? 0); $p = Person::find($pid); if (!$p || (int)$p['family_id'] !== $familyId) { flash('err', '找不到这个人。'); redirect('people'); } // 分组必须也是这个家庭的 —— 否则可以把人挂到别人家的分组上, // 名册按 group_id 分栏时就永远看不见他了 if ($gid > 0 && !isset(PersonGroup::byId($familyId)[$gid])) { flash('err', '找不到那个分组。'); redirect('people'); } Person::setGroup($pid, $gid > 0 ? $gid : null); flash('ok', $gid > 0 ? '已把「' . $p['name'] . '」归到「' . PersonGroup::name($familyId, $gid) . '」。' : '已把「' . $p['name'] . '」改成未分类。'); redirect('people'); break; // ==================== 我自己的资料 / 个人设置 ==================== // 顶栏头像点开 →「个人设置」。访客也能改自己的资料 —— // 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。 case 'people/me': // 「我的资料」已并进「个人设置」(设置分两个维度:系统设置 / 个人设置)。 // 旧链接、老书签、以及别处写死的 people/me 一律跳过去,不再留第二个入口。 redirect('settings/me'); break; case 'settings/me': Auth::requireLogin(); $p = Person::find((int)Auth::personId()); if (!$p) { flash('err', '找不到你自己的名册记录。'); redirect('home'); } clear_old(); render_me_settings($p); break; case 'settings/me_save': // 表单现在提交到这里 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); // 归一化裁剪框由前端浮层写进 avatar_crop;留空 = 没裁,退回居中 $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? [], (string)($_POST['avatar_crop'] ?? '')); if (!$up['ok'] && ($up['error'] ?? '') !== '') { $errors['avatar'] = $up['error']; } if ($errors) { set_old($_POST); render_me_settings($p, $errors); 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('settings/me'); break; // ==================== 分组顺序(只影响我自己) ==================== case 'settings/groups/move': Auth::requireLogin(); csrf_check(); $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $id = (int)($_POST['id'] ?? 0); $dir = ($_POST['dir'] ?? '') === 'up' ? 'up' : 'down'; $list = PersonGroup::orderFor($familyId, $me); $ids = array_map(fn($g) => (int)$g['id'], $list); $at = array_search($id, $ids, true); if ($at === false) { flash('err', '找不到这个分组。'); redirect('settings/me'); } // 挪到边界就什么也不做(按钮在两端本来就是 disabled 的, // 但地址栏里手敲一次也能到这儿,别让它把顺序搞乱) $to = $dir === 'up' ? $at - 1 : $at + 1; if ($to >= 0 && $to < count($ids)) { [$ids[$at], $ids[$to]] = [$ids[$to], $ids[$at]]; PersonGroup::saveOrder($familyId, $me, $ids); } redirect('settings/me'); break; // ==================== 关系分类的增删改名(全家共用) ==================== // 分类写在共享的表里,所以是"能写"的人才能动;访客只读,给个明确的说法再回去, // 而不是甩一张 403 页面(他明明是点了自己设置里的按钮)。 case 'settings/groups/add': Auth::requireLogin(); csrf_check(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能改关系分类。'); redirect('settings/me'); } $res = PersonGroup::add((int)Auth::familyId(), (string)($_POST['name'] ?? '')); flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '已加上分类「' . trim((string)($_POST['name'] ?? '')) . '」。' : (string)($res['error'] ?? '加不上。')); redirect('settings/me'); break; case 'settings/groups/rename': Auth::requireLogin(); csrf_check(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能改关系分类。'); redirect('settings/me'); } $res = PersonGroup::rename((int)Auth::familyId(), (int)($_POST['id'] ?? 0), (string)($_POST['name'] ?? '')); flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '分类名已改。' : (string)($res['error'] ?? '改不了。')); redirect('settings/me'); break; case 'settings/groups/delete': Auth::requireLogin(); csrf_check(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能改关系分类。'); redirect('settings/me'); } $res = PersonGroup::delete((int)Auth::familyId(), (int)($_POST['id'] ?? 0)); if ($res['ok']) { $n = (int)($res['moved'] ?? 0); flash('ok', $n > 0 ? '分类已删掉,那 ' . $n . ' 个人现在显示在「未分类」里,去名册重新归类即可。' : '分类已删掉(那一组本来也没人)。'); } else { flash('err', (string)($res['error'] ?? '删不了。')); } redirect('settings/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 === 'settings/me' || $back === 'people/me') { // 两个写法都认:老表单发的是 people/me(那一页现在是跳转) redirect('settings/me'); } redirect('people/show', ['id' => $target]); break; case 'people/new': Auth::requireWrite(); clear_old(); View::render('people/form', [ 'title' => '添加成员', 'person' => null, 'errors' => [], 'groups' => PersonGroup::byId((int)Auth::familyId()), ], '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, // 关系分组:下拉里的「未分类」是空 value,于是这里落成 null 'group_id' => !empty($_POST['group_id']) ? (int)$_POST['group_id'] : null, ] + read_birthday_input(); $errors = Person::validate($d); $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? [], (string)($_POST['avatar_crop'] ?? '')); 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'], 'groups' => PersonGroup::byId((int)Auth::familyId()), ], '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'], 'groups' => PersonGroup::byId((int)Auth::familyId()), ], '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']), 'groups' => PersonGroup::byId((int)Auth::familyId()), ], '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, 'group_id' => !empty($_POST['group_id']) ? (int)$_POST['group_id'] : null, ] + read_birthday_input($p); $errors = Person::validate($d); $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? [], (string)($_POST['avatar_crop'] ?? '')); 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'], 'groups' => PersonGroup::byId((int)Auth::familyId()), ], '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'], 'groups' => PersonGroup::byId((int)Auth::familyId()), ], '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; }