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

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

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

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

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

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

289 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 关系分组:一个人属于哪一组(家人 / 亲戚 / 同事 / 朋友 …),名册按它分组展示。
*
* 三条刻意的设计:
* ① **分类是全家共用的,顺序是各人各定的。**
* 老杨的同事,对全家来说都是同事 —— 所以分类只存一份(persons.group_id)。
* 但「哪一组排前面」纯属个人习惯,存在 user_settings.group_order 里,
* 每个人可以不一样(爸爸把「家人」排第一,妈妈把「亲戚」排第一)。
* ② **分类可以由用户自己加。** 所以是表而不是 ENUM —— 预置的七个只是初始值,
* 觉得不够就自己加,加错了可以改名或删掉。
* ③ **不用外键约束 group_id。** 老库升级只能 ADD COLUMN,加不上外键,
* 新装的又加了就会两边结构不一致;索性统一由程序保证 ——
* 删除分组时把挂靠的人置为「未分类」(NULL),不留悬空 id。
*/
declare(strict_types=1);
namespace App;
final class PersonGroup
{
/** 预置分组的来源在 Schema::DEFAULT_GROUPS(安装与升级共用一份,避免两处漂移) */
/**
* 给一个家庭补齐预置分组。幂等 —— 靠 uk_group_name 唯一键,重跑不会重复插入。
* 也不覆盖用户改过的名字(同名才算重复,改名后重跑会**补回**那个默认名,
* 这正是想要的:删掉的默认分组下次升级会回来,用户自定义的则不受影响)。
*/
public static function seedDefaults(int $familyId): void
{
if ($familyId <= 0) {
return;
}
$st = Database::get()->prepare(
'INSERT IGNORE INTO person_groups (family_id, name, position, is_builtin) VALUES (?, ?, ?, 1)'
);
foreach (Schema::DEFAULT_GROUPS as $name => $pos) {
$st->execute([$familyId, $name, $pos]);
}
}
/** 全部分组,id => 行(按默认顺序) */
public static function byId(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT * FROM person_groups WHERE family_id = ? ORDER BY position ASC, id ASC'
);
$st->execute([$familyId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['id']] = $r;
}
return $out;
}
/**
* 按**当前用户自定义的顺序**返回分组(用户没自定义过就按默认位置)。
*
* 自定义顺序里没提到的分组(用户排序之后才新建的)一律追加到最后 ——
* 这样「新建一个分组」不会因为它不在旧顺序里就消失不见。
*/
public static function orderFor(int $familyId, int $viewerId = 0): array
{
$rest = self::byId($familyId);
if (!$rest) {
return [];
}
if ($viewerId > 0) {
$raw = (string)UserSetting::get($viewerId, 'group_order', '');
$saved = $raw === '' ? null : json_decode($raw, true);
if (is_array($saved) && $saved) {
$ordered = [];
foreach ($saved as $id) {
$id = (int)$id;
if (isset($rest[$id])) {
$ordered[$id] = $rest[$id];
unset($rest[$id]);
}
}
foreach ($rest as $id => $g) {
$ordered[$id] = $g;
}
return array_values($ordered);
}
}
return array_values($rest);
}
/** 保存「我」的分组顺序(只影响我自己) */
public static function saveOrder(int $familyId, int $viewerId, array $ids): void
{
$valid = self::byId($familyId);
$clean = [];
foreach ($ids as $id) {
$id = (int)$id;
if (isset($valid[$id]) && !in_array($id, $clean, true)) {
$clean[] = $id;
}
}
// 没提到的补齐到末尾 —— 保存完整顺序,读回来才不会因为"缺项"而错位
foreach (array_keys($valid) as $id) {
if (!in_array($id, $clean, true)) {
$clean[] = $id;
}
}
UserSetting::set($viewerId, 'group_order', json_encode($clean, JSON_UNESCAPED_UNICODE));
}
/** 分组名(找不到就返回空串,调用方自己决定回退文案) */
public static function name(int $familyId, ?int $id): string
{
if (!$id) {
return '';
}
$st = Database::get()->prepare('SELECT name FROM person_groups WHERE family_id = ? AND id = ?');
$st->execute([$familyId, $id]);
return (string)($st->fetchColumn() ?: '');
}
/**
* 新增一个分组。
* @return array{ok:bool,id?:int,error?:string}
*/
public static function add(int $familyId, string $name): array
{
$name = trim($name);
$bad = self::validateName($name);
if ($bad !== '') {
return ['ok' => false, 'error' => $bad];
}
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND name = ?'
);
$st->execute([$familyId, $name]);
if ((int)$st->fetchColumn() > 0) {
return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。'];
}
// ⚠️ 别写成 `$max = (int)$db->prepare(...)->execute([...])`:
// PDOStatement::execute() 返回 bool,强转永远是 1 —— 位置会算错。
$st2 = Database::get()->prepare('SELECT COALESCE(MAX(position), 0) FROM person_groups WHERE family_id = ?');
$st2->execute([$familyId]);
$max = (int)$st2->fetchColumn();
Database::get()->prepare(
'INSERT INTO person_groups (family_id, name, position, is_builtin) VALUES (?, ?, ?, 0)'
)->execute([$familyId, $name, max($max + 10, 80)]);
return ['ok' => true, 'id' => (int)Database::get()->lastInsertId()];
}
/** @return array{ok:bool,error?:string} */
public static function rename(int $familyId, int $id, string $name): array
{
$name = trim($name);
$bad = self::validateName($name);
if ($bad !== '') {
return ['ok' => false, 'error' => $bad];
}
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND name = ? AND id <> ?'
);
$st->execute([$familyId, $name, $id]);
if ((int)$st->fetchColumn() > 0) {
return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。'];
}
$st = Database::get()->prepare('UPDATE person_groups SET name = ? WHERE family_id = ? AND id = ?');
$st->execute([$name, $familyId, $id]);
if ($st->rowCount() === 0) {
return ['ok' => false, 'error' => '找不到这个分组。'];
}
return ['ok' => true];
}
/**
* 删除一个分组。挂在这个分组下的人**不会消失**,只是变成「未分类」——
* 名册上会多出一个「未分类」组提醒去归类。
* 最后一个分组不给删:全删光就没有任何分类可用了,那是死路。
*
* @return array{ok:bool,error?:string,moved?:int}
*/
public static function delete(int $familyId, int $id): array
{
$all = self::byId($familyId);
if (!isset($all[$id])) {
return ['ok' => false, 'error' => '找不到这个分组。'];
}
if (count($all) <= 1) {
return ['ok' => false, 'error' => '至少要留一个分组 —— 全删光就没法给成员归类了。'];
}
$db = Database::get();
$st = $db->prepare('SELECT COUNT(*) FROM persons WHERE family_id = ? AND group_id = ?');
$st->execute([$familyId, $id]);
$moved = (int)$st->fetchColumn();
$db->prepare('UPDATE persons SET group_id = NULL WHERE family_id = ? AND group_id = ?')
->execute([$familyId, $id]);
$db->prepare('DELETE FROM person_groups WHERE family_id = ? AND id = ?')
->execute([$familyId, $id]);
// 各人自定义顺序里可能留着这个 id,清掉免得越积越脏
self::pruneOrder($familyId, $id);
return ['ok' => true, 'moved' => $moved];
}
/** 分组名规则:1–20 字、不许只剩空白 */
private static function validateName(string $name): string
{
if ($name === '') {
return '分组名不能为空。';
}
if (mb_strlen($name, 'UTF-8') > 20) {
return '分组名请控制在 20 字以内。';
}
return '';
}
/** 从所有人的自定义顺序里剔除某个分组 id(删组之后调用) */
private static function pruneOrder(int $familyId, int $removedId): void
{
$db = Database::get();
$st = $db->prepare(
'SELECT us.person_id, us.v FROM user_settings us
JOIN persons p ON p.id = us.person_id
WHERE p.family_id = ? AND us.k = ?'
);
$st->execute([$familyId, 'group_order']);
foreach ($st->fetchAll() as $row) {
$ids = json_decode((string)$row['v'], true);
if (!is_array($ids)) {
continue;
}
$keep = array_values(array_filter($ids, fn($x) => (int)$x !== $removedId));
$db->prepare('UPDATE user_settings SET v = ? WHERE person_id = ? AND k = ?')
->execute([json_encode(array_map('intval', $keep)), (int)$row['person_id'], 'group_order']);
}
}
/**
* 老库升级:把还没有分组的人按名册类型补一个合理默认。
* 家庭成员 → 家人;外部联系人 → 认识。
* 只补 NULL,用户自己改过的一律不动(幂等)。
*/
public static function backfillMissing(\PDO $db): void
{
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
$fid = (int)$fam['id'];
// 家庭可能还没套用预置分组(ensure 的 seed 在表建好之后才跑)
$st = $db->prepare('SELECT COUNT(*) FROM person_groups WHERE family_id = ?');
$st->execute([$fid]);
if ((int)$st->fetchColumn() === 0) {
self::seedDefaults($fid);
}
foreach ([['member', '家人'], ['contact', '认识']] as [$kind, $name]) {
$gid = self::idByName($fid, $name);
if ($gid === 0) {
continue;
}
$db->prepare(
'UPDATE persons SET group_id = ?
WHERE family_id = ? AND kind = ? AND group_id IS NULL'
)->execute([$gid, $fid, $kind]);
}
}
}
/** 按名字找分组 id(找不到返回 0) */
public static function idByName(int $familyId, string $name): int
{
$st = Database::get()->prepare('SELECT id FROM person_groups WHERE family_id = ? AND name = ?');
$st->execute([$familyId, $name]);
return (int)($st->fetchColumn() ?: 0);
}
/** 每个分组里有多少人(没分组的人记在键 0 上) */
public static function counts(int $familyId, string $kind = 'member'): array
{
$st = Database::get()->prepare(
'SELECT COALESCE(group_id, 0) AS g, COUNT(*) AS n
FROM persons WHERE family_id = ? AND kind = ?
GROUP BY COALESCE(group_id, 0)'
);
$st->execute([$familyId, $kind]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['g']] = (int)$r['n'];
}
return $out;
}
}