Files
FamilyBook/app/PersonGroup.php
T
laoyang ed38a40c08 名册每人一份 · 分组两层 · 拖动排序 · 表单合一 · 裁剪可缩放
按用户十点反馈逐条落地(v5,Schema::VERSION = 5):

- 名册改成每人一份:新表 person_roster(owner_id, person_id, group_id,
  sort_order, source)。默认只看「我添加的 + 别人分享给我的」;
  source='seeded' 是升级时从老 persons.group_id 播进来的(先播数据再删列)。
- 排序不再手填:名册页直接拖动卡片(整版提交,一次 applyLayout 落库),
  无 JS 时卡片上的「调整分组」表单照旧可用。
- 分组分两层:管理员在「系统设置」维护公共分组(owner_id=0),
  用户在「个人设置」加自己的分组;显示顺序各人各定、互不影响。
- 名册可共享:把名片分享给别的用户(people/share);添加成员时若系统里
  已有这个人,可直接复用(people/pick),不必重复录入。
- 「编辑成员」与「改我自己的资料」合并为同一张表(templates/people/form.php),
  两个入口 href 完全相同,h1 按改谁切换文案;个人设置页只留
  资料入口 + 称呼 + 我的分组三块,删除 templates/settings/_profile.php。
- 系统设置收进头像下拉,顶栏只剩 首页/家谱/名册。
- 裁剪器加缩放(滑块 1-4x、Ctrl+滚轮、还原):缩放只改视图大小,
  落库的归一化矩形保持不变。
- 权限:管理员可以改任何人的权限(含自己),只拦「最后一位管理员」被降级。
- 去掉首页「我的一句话」后的链接、家谱说明段、以及「还没归到任何分组」提示。

迁移安全:数据回填(PersonRoster::seedFromPersons)先于删列执行、且不进
tryStep 容错;失败步骤记入 settings.schema_upgrade_warning 并在系统设置页提示。
线上已验证:schema_upgrade_warning 为空,person_roster/person_groups.owner_id
就位,persons.group_id / sort_order 已删。

验证:php -l 48 文件;本地 e2e 277/0、老库升级 81/0、桌面几何 108/0、
移动端几何 180/0、交互 96/0;反向验证 38 条注入 + 2 条缩放注入全部命中;
部署静态断言 53/0,站点自检 7/7(--resolve 锁本机走 443,探针真的进 PHP)。
2026-09-30 13:24:26 +08:00

359 lines
15 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
/**
* 关系分组:一个人属于哪一组(家人 / 亲戚 / 同事 / 朋友 …),名册按它分栏展示。
*
* 四条刻意的设计(第一、二条是被纠正过之后定下来的):
* ① **一套是公共的,一套是我自己的。** `owner_id = 0` 是管理员在「系统设置」
* 里维护的公共分组(全家都能用);`owner_id = 我` 是我在「个人设置」里加的,
* 只有我自己看得见。所以「某人的同事」如果全家都认,管理员建公共的;
* 只有我觉得该有「球友」,我自己加一个,不影响别人。
* ② **顺序是各人各定的。** 「哪一组排前面」纯属个人习惯,存在
* user_settings.group_order 里(每人一份),改它不会动到公共的默认顺序。
* ③ **分类可以由用户自己加。** 所以是表而不是 ENUM —— 预置的七个只是初始值。
* ④ **不用外键约束 person_roster.group_id。** 老库升级只能 ADD COLUMN、加不上外键,
* 新装的又加了就会两边结构不一致;索性统一由程序保证 ——
* 删除分组时把挂靠的名册条目置为「未分类」(NULL),不留悬空 id。
*/
declare(strict_types=1);
namespace App;
final class PersonGroup
{
/** 公共分组(管理员维护,全家共用)的 owner_id */
public const PUBLIC_OWNER = 0;
/** 预置分组的来源在 Schema::DEFAULT_GROUPS(安装与升级共用一份,避免两处漂移) */
/**
* 给一个家庭补齐**公共**分组。幂等 —— 靠 uk_group_owner_name 唯一键,重跑不会重复插入。
* 也不覆盖用户改过的名字(同名才算重复,改名后重跑会**补回**那个默认名,
* 这正是想要的:删掉的默认分组下次升级会回来,用户自定义的则不受影响)。
*/
public static function seedDefaults(int $familyId, int $ownerId = self::PUBLIC_OWNER): void
{
if ($familyId <= 0) {
return;
}
$st = Database::get()->prepare(
'INSERT IGNORE INTO person_groups (family_id, owner_id, name, position, is_builtin)
VALUES (?, ?, ?, ?, 1)'
);
foreach (Schema::DEFAULT_GROUPS as $name => $pos) {
$st->execute([$familyId, max(0, $ownerId), $name, $pos]);
}
}
/** 某一套分组(公共传 0,个人传自己的 person_id),id => 行,按默认顺序 */
public static function forOwner(int $familyId, int $ownerId): array
{
$st = Database::get()->prepare(
'SELECT * FROM person_groups WHERE family_id = ? AND owner_id = ?
ORDER BY position ASC, id ASC'
);
$st->execute([$familyId, max(0, $ownerId)]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['id']] = $r;
}
return $out;
}
/** 这个家庭里的全部分组(含所有人的私自分组)—— 只用来校验"这个 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 visible(int $familyId, int $viewerId): array
{
$viewerId = max(0, $viewerId);
$st = Database::get()->prepare(
'SELECT * FROM person_groups
WHERE family_id = ? AND (owner_id = 0 OR owner_id = ?)
ORDER BY owner_id ASC, position ASC, id ASC'
);
$st->execute([$familyId, $viewerId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['id']] = $r;
}
return $out;
}
/** visible() 的 id 集合版本,用来做「这个分组 id 合不合法」的判断 */
public static function visibleIds(int $familyId, int $viewerId): array
{
return array_fill_keys(array_keys(self::visible($familyId, $viewerId)), true);
}
/**
* 按**当前用户自定义的顺序**返回分组(用户没自定义过就按默认位置)。
*
* 自定义顺序里没提到的分组(用户排序之后才新建的)一律追加到最后 ——
* 这样「新建一个分组」不会因为它不在旧顺序里就消失不见。
*/
public static function orderFor(int $familyId, int $viewerId = 0): array
{
$rest = self::visible($familyId, $viewerId);
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::visible($familyId, $viewerId);
$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));
}
/** 按给定的 id 顺序重写某一套分组的 position(公共分组的「全家默认顺序」靠它) */
public static function savePositions(int $familyId, int $ownerId, array $ids): void
{
$db = Database::get();
$up = $db->prepare(
'UPDATE person_groups SET position = ? WHERE family_id = ? AND owner_id = ? AND id = ?'
);
$pos = 0;
foreach ($ids as $id) {
$pos += 10;
$up->execute([$pos, $familyId, max(0, $ownerId), (int)$id]);
}
}
/** 分组名(找不到就返回空串,调用方自己决定回退文案) */
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() ?: '');
}
/**
* 新增一个分组。
* @param int $ownerId 0=公共(只有管理员能建,调用方把关);否则=建给自己的
* @return array{ok:bool,id?:int,error?:string}
*/
public static function add(int $familyId, string $name, int $ownerId = self::PUBLIC_OWNER): array
{
$ownerId = max(0, $ownerId);
$name = trim($name);
$bad = self::validateName($name);
if ($bad !== '') {
return ['ok' => false, 'error' => $bad];
}
$db = Database::get();
$st = $db->prepare(
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND owner_id = ? AND name = ?'
);
$st->execute([$familyId, $ownerId, $name]);
if ((int)$st->fetchColumn() > 0) {
return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。'];
}
// ⚠️ 别写成 `$max = (int)$db->prepare(...)->execute([...])`:
// PDOStatement::execute() 返回 bool,强转永远是 1 —— 位置会算错。
$st2 = $db->prepare(
'SELECT COALESCE(MAX(position), 0) FROM person_groups WHERE family_id = ? AND owner_id = ?'
);
$st2->execute([$familyId, $ownerId]);
$max = (int)$st2->fetchColumn();
$db->prepare(
'INSERT INTO person_groups (family_id, owner_id, name, position, is_builtin)
VALUES (?, ?, ?, ?, 0)'
)->execute([$familyId, $ownerId, $name, max($max + 10, 80)]);
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
}
/**
* 改名。$ownerId 用来确认"你改的是你自己那一套里的分组"——
* 公共分组只有管理员能改(调用方把关),别人的私自分组连管理员也不该动。
* @return array{ok:bool,error?:string}
*/
public static function rename(int $familyId, int $id, string $name, int $ownerId): array
{
$ownerId = max(0, $ownerId);
$name = trim($name);
$bad = self::validateName($name);
if ($bad !== '') {
return ['ok' => false, 'error' => $bad];
}
$db = Database::get();
$own = $db->prepare(
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND owner_id = ? AND id = ?'
);
$own->execute([$familyId, $ownerId, $id]);
if ((int)$own->fetchColumn() === 0) {
return ['ok' => false, 'error' => '找不到这个分组(可能不是你那一套里的)。'];
}
$st = $db->prepare(
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND owner_id = ? AND name = ? AND id <> ?'
);
$st->execute([$familyId, $ownerId, $name, $id]);
if ((int)$st->fetchColumn() > 0) {
return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。'];
}
$up = $db->prepare(
'UPDATE person_groups SET name = ? WHERE family_id = ? AND owner_id = ? AND id = ?'
);
$up->execute([$name, $familyId, $ownerId, $id]);
if ($up->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, int $ownerId): array
{
$ownerId = max(0, $ownerId);
$all = self::forOwner($familyId, $ownerId);
if (!isset($all[$id])) {
return ['ok' => false, 'error' => '找不到这个分组(可能不是你那一套里的)。'];
}
if ($ownerId === self::PUBLIC_OWNER && count($all) <= 1) {
return ['ok' => false, 'error' => '至少要留一个公共分组 —— 全删光就没法给成员归类了。'];
}
$db = Database::get();
$st = $db->prepare('SELECT COUNT(*) FROM person_roster WHERE group_id = ?');
$st->execute([$id]);
$moved = (int)$st->fetchColumn();
// ⚠️ 释放范围是**所有**名册条目(不限 owner):公共分组人人都能用,
// 只清自己那几条会让别人名册里留下指向已删分组的 id(那一栏就永远空着、
// 而对应的卡片在同一次渲染里彻底消失)。
$db->prepare('UPDATE person_roster SET group_id = NULL WHERE group_id = ?')->execute([$id]);
$db->prepare('DELETE FROM person_groups WHERE family_id = ? AND owner_id = ? AND id = ?')
->execute([$familyId, $ownerId, $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));
$prep = $db->prepare('UPDATE user_settings SET v = ? WHERE person_id = ? AND k = ?');
$prep->execute([json_encode(array_map('intval', $keep)), (int)$row['person_id'], 'group_order']);
}
}
/** 按名字找分组 id(找不到返回 0)。默认在**公共**分组里找。 */
public static function idByName(int $familyId, string $name, int $ownerId = self::PUBLIC_OWNER): int
{
$st = Database::get()->prepare(
'SELECT id FROM person_groups WHERE family_id = ? AND owner_id = ? AND name = ?'
);
$st->execute([$familyId, max(0, $ownerId), $name]);
return (int)($st->fetchColumn() ?: 0);
}
/**
* 某个分组下有多少人 —— **从我自己的名册里数**。
*
* 这是 v5 的关键语义变化:老版本数的是"全家名册里 group_id = 它的有几个人",
* 那种数字对"我自己该怎么归类"没有指导意义(别人把人归到哪一组关我什么事)。
* 现在数的是"我名册里归到这一组的有几个",用来给个人设置里的分组列表做提示。
*/
public static function counts(int $familyId, int $ownerId, string $kind = 'member'): array
{
$st = Database::get()->prepare(
'SELECT COALESCE(r.group_id, 0) AS g, COUNT(*) AS n
FROM person_roster r
JOIN persons p ON p.id = r.person_id
WHERE r.family_id = ? AND r.owner_id = ? AND p.kind = ?
GROUP BY COALESCE(r.group_id, 0)'
);
$st->execute([$familyId, $ownerId, $kind]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['g']] = (int)$r['n'];
}
return $out;
}
}