名册每人一份 · 分组两层 · 拖动排序 · 表单合一 · 裁剪可缩放

按用户十点反馈逐条落地(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)。
This commit is contained in:
2026-09-30 13:24:26 +08:00
parent 1c460a0e83
commit ed38a40c08
21 changed files with 1780 additions and 594 deletions
+159 -89
View File
@@ -1,17 +1,18 @@
<?php
/**
* 关系分组:一个人属于哪一组(家人 / 亲戚 / 同事 / 朋友 …),名册按它分组展示。
* 关系分组:一个人属于哪一组(家人 / 亲戚 / 同事 / 朋友 …),名册按它分栏展示。
*
* 三条刻意的设计:
* ① **分类是全家共用的,顺序是各人各定的。**
* 老杨的同事,对全家来说都是同事 —— 所以分类只存一份(persons.group_id)。
* 但「哪一组排前面」纯属个人习惯,存在 user_settings.group_order 里,
* 每个人可以不一样(爸爸把「家人」排第一,妈妈把「亲戚」排第一)。
* ② **分类可以由用户自己加。** 所以是表而不是 ENUM —— 预置的七个只是初始值,
* 觉得不够就自己加,加错了可以改名或删掉。
* ③ **不用外键约束 group_id。** 老库升级只能 ADD COLUMN,加不上外键,
* 四条刻意的设计(第一、二条是被纠正过之后定下来的):
* ① **一套是公共的,一套是我自己的。** `owner_id = 0` 是管理员在「系统设置」
* 里维护的公共分组(全家都能用);`owner_id = 我` 是我在「个人设置」里加的,
* 只有我自己看得见。所以「某人的同事」如果全家都认,管理员建公共的;
* 只有我觉得该有「球友」,我自己加一个,不影响别人。
* ② **顺序是各人各定的。** 「哪一组排前面」纯属个人习惯,存在
* user_settings.group_order 里(每人一份),改它不会动到公共的默认顺序。
* ③ **分类可以由用户自己加。** 所以是表而不是 ENUM —— 预置的七个只是初始值。
* ④ **不用外键约束 person_roster.group_id。** 老库升级只能 ADD COLUMN、加不上外键,
* 新装的又加了就会两边结构不一致;索性统一由程序保证 ——
* 删除分组时把挂靠的人置为「未分类」(NULL),不留悬空 id。
* 删除分组时把挂靠的名册条目置为「未分类」(NULL),不留悬空 id。
*/
declare(strict_types=1);
@@ -20,27 +21,46 @@ namespace App;
final class PersonGroup
{
/** 公共分组(管理员维护,全家共用)的 owner_id */
public const PUBLIC_OWNER = 0;
/** 预置分组的来源在 Schema::DEFAULT_GROUPS(安装与升级共用一份,避免两处漂移) */
/**
* 给一个家庭补齐预置分组。幂等 —— 靠 uk_group_name 唯一键,重跑不会重复插入。
* 给一个家庭补齐**公共**分组。幂等 —— 靠 uk_group_owner_name 唯一键,重跑不会重复插入。
* 也不覆盖用户改过的名字(同名才算重复,改名后重跑会**补回**那个默认名,
* 这正是想要的:删掉的默认分组下次升级会回来,用户自定义的则不受影响)。
*/
public static function seedDefaults(int $familyId): void
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, name, position, is_builtin) VALUES (?, ?, ?, 1)'
'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, $name, $pos]);
$st->execute([$familyId, max(0, $ownerId), $name, $pos]);
}
}
/** 全部分组,id => 行(按默认顺序) */
/** 某一套分组(公共传 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(
@@ -54,6 +74,34 @@ final class PersonGroup
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);
}
/**
* 按**当前用户自定义的顺序**返回分组(用户没自定义过就按默认位置)。
*
@@ -62,7 +110,7 @@ final class PersonGroup
*/
public static function orderFor(int $familyId, int $viewerId = 0): array
{
$rest = self::byId($familyId);
$rest = self::visible($familyId, $viewerId);
if (!$rest) {
return [];
}
@@ -90,7 +138,7 @@ final class PersonGroup
/** 保存「我」的分组顺序(只影响我自己) */
public static function saveOrder(int $familyId, int $viewerId, array $ids): void
{
$valid = self::byId($familyId);
$valid = self::visible($familyId, $viewerId);
$clean = [];
foreach ($ids as $id) {
$id = (int)$id;
@@ -107,6 +155,20 @@ final class PersonGroup
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
{
@@ -120,81 +182,107 @@ final class PersonGroup
/**
* 新增一个分组。
* @param int $ownerId 0=公共(只有管理员能建,调用方把关);否则=建给自己的
* @return array{ok:bool,id?:int,error?:string}
*/
public static function add(int $familyId, string $name): array
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];
}
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND name = ?'
$db = Database::get();
$st = $db->prepare(
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND owner_id = ? AND name = ?'
);
$st->execute([$familyId, $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 = Database::get()->prepare('SELECT COALESCE(MAX(position), 0) FROM person_groups WHERE family_id = ?');
$st2->execute([$familyId]);
$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();
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()];
$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()];
}
/** @return array{ok:bool,error?:string} */
public static function rename(int $familyId, int $id, string $name): array
/**
* 改名。$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];
}
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND name = ? AND id <> ?'
$db = Database::get();
$own = $db->prepare(
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND owner_id = ? AND id = ?'
);
$st->execute([$familyId, $name, $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 . '」的分组了。'];
}
$st = Database::get()->prepare('UPDATE person_groups SET name = ? WHERE family_id = ? AND id = ?');
$st->execute([$name, $familyId, $id]);
if ($st->rowCount() === 0) {
$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): array
public static function delete(int $familyId, int $id, int $ownerId): array
{
$all = self::byId($familyId);
$ownerId = max(0, $ownerId);
$all = self::forOwner($familyId, $ownerId);
if (!isset($all[$id])) {
return ['ok' => false, 'error' => '找不到这个分组。'];
return ['ok' => false, 'error' => '找不到这个分组(可能不是你那一套里的)。'];
}
if (count($all) <= 1) {
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 persons WHERE family_id = ? AND group_id = ?');
$st->execute([$familyId, $id]);
$st = $db->prepare('SELECT COUNT(*) FROM person_roster WHERE group_id = ?');
$st->execute([$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]);
// ⚠️ 释放范围是**所有**名册条目(不限 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);
@@ -229,56 +317,38 @@ final class PersonGroup
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']);
$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']);
}
}
/**
* 老库升级:把还没有分组的人按名册类型补一个合理默认。
* 家庭成员 → 家人;外部联系人 → 认识。
* 只补 NULL,用户自己改过的一律不动(幂等)。
*/
public static function backfillMissing(\PDO $db): void
/** 按名字找分组 id(找不到返回 0)。默认在**公共**分组里找。 */
public static function idByName(int $familyId, string $name, int $ownerId = self::PUBLIC_OWNER): int
{
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]);
$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);
}
/** 每个分组里有多少人(没分组的人记在键 0 上) */
public static function counts(int $familyId, string $kind = 'member'): array
/**
* 某个分组下有多少人 —— **从我自己的名册里数**。
*
* 这是 v5 的关键语义变化:老版本数的是"全家名册里 group_id = 它的有几个人",
* 那种数字对"我自己该怎么归类"没有指导意义(别人把人归到哪一组关我什么事)。
* 现在数的是"我名册里归到这一组的有几个",用来给个人设置里的分组列表做提示。
*/
public static function counts(int $familyId, int $ownerId, 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)'
'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, $kind]);
$st->execute([$familyId, $ownerId, $kind]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['g']] = (int)$r['n'];