289 lines
12 KiB
PHP
289 lines
12 KiB
PHP
<?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;
|
|||
|
|
}
|
|||
|
|
}
|