2026-09-30 11:06:22 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
2026-09-30 13:24:26 +08:00
|
|
|
|
* 关系分组:一个人属于哪一组(家人 / 亲戚 / 同事 / 朋友 …),名册按它分栏展示。
|
2026-09-30 11:06:22 +08:00
|
|
|
|
*
|
2026-09-30 13:24:26 +08:00
|
|
|
|
* 四条刻意的设计(第一、二条是被纠正过之后定下来的):
|
|
|
|
|
|
* ① **一套是公共的,一套是我自己的。** `owner_id = 0` 是管理员在「系统设置」
|
|
|
|
|
|
* 里维护的公共分组(全家都能用);`owner_id = 我` 是我在「个人设置」里加的,
|
|
|
|
|
|
* 只有我自己看得见。所以「某人的同事」如果全家都认,管理员建公共的;
|
|
|
|
|
|
* 只有我觉得该有「球友」,我自己加一个,不影响别人。
|
|
|
|
|
|
* ② **顺序是各人各定的。** 「哪一组排前面」纯属个人习惯,存在
|
|
|
|
|
|
* user_settings.group_order 里(每人一份),改它不会动到公共的默认顺序。
|
|
|
|
|
|
* ③ **分类可以由用户自己加。** 所以是表而不是 ENUM —— 预置的七个只是初始值。
|
|
|
|
|
|
* ④ **不用外键约束 person_roster.group_id。** 老库升级只能 ADD COLUMN、加不上外键,
|
2026-09-30 11:06:22 +08:00
|
|
|
|
* 新装的又加了就会两边结构不一致;索性统一由程序保证 ——
|
2026-09-30 13:24:26 +08:00
|
|
|
|
* 删除分组时把挂靠的名册条目置为「未分类」(NULL),不留悬空 id。
|
2026-09-30 11:06:22 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App;
|
|
|
|
|
|
|
|
|
|
|
|
final class PersonGroup
|
|
|
|
|
|
{
|
2026-09-30 13:24:26 +08:00
|
|
|
|
/** 公共分组(管理员维护,全家共用)的 owner_id */
|
|
|
|
|
|
public const PUBLIC_OWNER = 0;
|
|
|
|
|
|
|
2026-10-19 17:27:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 🔴 v63(用户 2026-10-19 第 5 条):「每个标签增加一个颜色。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* 色板 = 标签可以挑的颜色。与「模块主题色」(`Module::PRESET_COLORS`)**同一条
|
|
|
|
|
|
* 审美规矩**:中低饱和、中低明度 —— 标签是纸面上的一行字,颜色太艳会盖过内容。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 挑色是"选"不是"填":界面上只给这些色块(外加一个系统取色器兜底),
|
|
|
|
|
|
* 服务端一律按白名单校验(`sanitizeColor`),别让手改表单塞进 CSS。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public const SWATCHES = [
|
|
|
|
|
|
'#A84A28', '#8A6B4B', '#4A7C82', '#7A4E8C', '#3F7D5A', '#B0763A',
|
|
|
|
|
|
'#5566A8', '#A8574F', '#5C8A48', '#6B5B95', '#9A5C7A', '#3D6B8E',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
/** 颜色白名单:只接受 #rrggbb,其余回退到 $fallback(同 `Nav::safeColor` 的做法) */
|
|
|
|
|
|
public static function sanitizeColor(string $c, string $fallback): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return preg_match('/^#[0-9a-fA-F]{6}$/', trim($c)) ? strtolower(trim($c)) : $fallback;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 这个标签"实际显示成什么颜色"。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 没选过颜色(`color` 为 NULL/空)时**按 id 稳定地**从色板里挑一个 ——
|
|
|
|
|
|
* 不能用"列表里的第几个"来挑:那样改一次顺序,满屏标签颜色全跳一遍,
|
|
|
|
|
|
* 用户会以为自己点错了。用 id 取模则"这个标签一辈子是这个色",直到用户改它。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function swatch(array $g): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$raw = trim((string)($g['color'] ?? ''));
|
|
|
|
|
|
if ($raw !== '') {
|
|
|
|
|
|
return self::sanitizeColor($raw, self::SWATCHES[0]);
|
|
|
|
|
|
}
|
|
|
|
|
|
$id = (int)($g['id'] ?? 0);
|
|
|
|
|
|
$n = count(self::SWATCHES);
|
|
|
|
|
|
return self::SWATCHES[$id % $n];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 11:06:22 +08:00
|
|
|
|
/** 预置分组的来源在 Schema::DEFAULT_GROUPS(安装与升级共用一份,避免两处漂移) */
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-30 13:24:26 +08:00
|
|
|
|
* 给一个家庭补齐**公共**分组。幂等 —— 靠 uk_group_owner_name 唯一键,重跑不会重复插入。
|
2026-09-30 11:06:22 +08:00
|
|
|
|
* 也不覆盖用户改过的名字(同名才算重复,改名后重跑会**补回**那个默认名,
|
|
|
|
|
|
* 这正是想要的:删掉的默认分组下次升级会回来,用户自定义的则不受影响)。
|
|
|
|
|
|
*/
|
2026-09-30 13:24:26 +08:00
|
|
|
|
public static function seedDefaults(int $familyId, int $ownerId = self::PUBLIC_OWNER): void
|
2026-09-30 11:06:22 +08:00
|
|
|
|
{
|
|
|
|
|
|
if ($familyId <= 0) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
$st = Database::get()->prepare(
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'INSERT IGNORE INTO person_groups (family_id, owner_id, name, position, is_builtin)
|
|
|
|
|
|
VALUES (?, ?, ?, ?, 1)'
|
2026-09-30 11:06:22 +08:00
|
|
|
|
);
|
|
|
|
|
|
foreach (Schema::DEFAULT_GROUPS as $name => $pos) {
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$st->execute([$familyId, max(0, $ownerId), $name, $pos]);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
/** 某一套分组(公共传 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 是本家庭的" */
|
2026-09-30 11:06:22 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* **这个人在名册里能用哪些分组** = 公共的 + 他自己的私自分组。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 访客 / 还没定过个人分组的人也至少能拿到公共的那一套,
|
|
|
|
|
|
* 所以"管理员建了分组但没人用得上"不会发生。
|
|
|
|
|
|
*/
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 11:06:22 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 按**当前用户自定义的顺序**返回分组(用户没自定义过就按默认位置)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 自定义顺序里没提到的分组(用户排序之后才新建的)一律追加到最后 ——
|
|
|
|
|
|
* 这样「新建一个分组」不会因为它不在旧顺序里就消失不见。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function orderFor(int $familyId, int $viewerId = 0): array
|
|
|
|
|
|
{
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$rest = self::visible($familyId, $viewerId);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
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
|
|
|
|
|
|
{
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$valid = self::visible($familyId, $viewerId);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
$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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
/** 按给定的 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]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 11:06:22 +08:00
|
|
|
|
/** 分组名(找不到就返回空串,调用方自己决定回退文案) */
|
|
|
|
|
|
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() ?: '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 新增一个分组。
|
2026-09-30 13:24:26 +08:00
|
|
|
|
* @param int $ownerId 0=公共(只有管理员能建,调用方把关);否则=建给自己的
|
2026-09-30 11:06:22 +08:00
|
|
|
|
* @return array{ok:bool,id?:int,error?:string}
|
|
|
|
|
|
*/
|
2026-10-19 17:27:37 +08:00
|
|
|
|
public static function add(int $familyId, string $name, int $ownerId = self::PUBLIC_OWNER,
|
|
|
|
|
|
string $color = ''): array
|
2026-09-30 11:06:22 +08:00
|
|
|
|
{
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$ownerId = max(0, $ownerId);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
$name = trim($name);
|
|
|
|
|
|
$bad = self::validateName($name);
|
|
|
|
|
|
if ($bad !== '') {
|
|
|
|
|
|
return ['ok' => false, 'error' => $bad];
|
|
|
|
|
|
}
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$db = Database::get();
|
|
|
|
|
|
$st = $db->prepare(
|
|
|
|
|
|
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND owner_id = ? AND name = ?'
|
2026-09-30 11:06:22 +08:00
|
|
|
|
);
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$st->execute([$familyId, $ownerId, $name]);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
if ((int)$st->fetchColumn() > 0) {
|
|
|
|
|
|
return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。'];
|
|
|
|
|
|
}
|
|
|
|
|
|
// ⚠️ 别写成 `$max = (int)$db->prepare(...)->execute([...])`:
|
|
|
|
|
|
// PDOStatement::execute() 返回 bool,强转永远是 1 —— 位置会算错。
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$st2 = $db->prepare(
|
|
|
|
|
|
'SELECT COALESCE(MAX(position), 0) FROM person_groups WHERE family_id = ? AND owner_id = ?'
|
|
|
|
|
|
);
|
|
|
|
|
|
$st2->execute([$familyId, $ownerId]);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
$max = (int)$st2->fetchColumn();
|
2026-10-19 17:27:37 +08:00
|
|
|
|
// 🔴 v63:颜色可空 —— 空串存 NULL(= "还没选过",见 swatch() 的说明)。
|
|
|
|
|
|
// 传了颜色就按白名单归一(非法值退回空 = 也当没选过,不写脏数据进库)。
|
|
|
|
|
|
$col = trim($color) === '' ? null : self::sanitizeColor($color, '');
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$db->prepare(
|
2026-10-19 17:27:37 +08:00
|
|
|
|
'INSERT INTO person_groups (family_id, owner_id, name, color, position, is_builtin)
|
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, 0)'
|
|
|
|
|
|
)->execute([$familyId, $ownerId, $name, $col, max($max + 10, 80)]);
|
2026-09-30 13:24:26 +08:00
|
|
|
|
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
|
2026-09-30 11:06:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 改名。$ownerId 用来确认"你改的是你自己那一套里的分组"——
|
|
|
|
|
|
* 公共分组只有管理员能改(调用方把关),别人的私自分组连管理员也不该动。
|
2026-10-19 17:27:37 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v63:顺手支持改颜色(`$color === null` = **不动颜色**)。
|
|
|
|
|
|
* ⚠️ 用 null 而不是空串表示"不改":空串是"清掉颜色"这个合法意图
|
|
|
|
|
|
* (退回按 id 取默认色),两者必须能区分 —— 这是"部分更新"接口
|
|
|
|
|
|
* 最容易写错的一处(传不传那个字段,语义完全不同)。
|
2026-09-30 13:24:26 +08:00
|
|
|
|
* @return array{ok:bool,error?:string}
|
|
|
|
|
|
*/
|
2026-10-19 17:27:37 +08:00
|
|
|
|
public static function rename(int $familyId, int $id, string $name, int $ownerId,
|
|
|
|
|
|
?string $color = null): array
|
2026-09-30 11:06:22 +08:00
|
|
|
|
{
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$ownerId = max(0, $ownerId);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
$name = trim($name);
|
|
|
|
|
|
$bad = self::validateName($name);
|
|
|
|
|
|
if ($bad !== '') {
|
|
|
|
|
|
return ['ok' => false, 'error' => $bad];
|
|
|
|
|
|
}
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$db = Database::get();
|
|
|
|
|
|
$own = $db->prepare(
|
|
|
|
|
|
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND owner_id = ? AND id = ?'
|
2026-09-30 11:06:22 +08:00
|
|
|
|
);
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$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]);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
if ((int)$st->fetchColumn() > 0) {
|
|
|
|
|
|
return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。'];
|
|
|
|
|
|
}
|
2026-10-19 17:27:37 +08:00
|
|
|
|
if ($color === null) {
|
|
|
|
|
|
$up = $db->prepare(
|
|
|
|
|
|
'UPDATE person_groups SET name = ? WHERE family_id = ? AND owner_id = ? AND id = ?'
|
|
|
|
|
|
);
|
|
|
|
|
|
$up->execute([$name, $familyId, $ownerId, $id]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$col = trim($color) === '' ? null : self::sanitizeColor($color, '');
|
|
|
|
|
|
$up = $db->prepare(
|
|
|
|
|
|
'UPDATE person_groups SET name = ?, color = ? WHERE family_id = ? AND owner_id = ? AND id = ?'
|
|
|
|
|
|
);
|
|
|
|
|
|
$up->execute([$name, $col, $familyId, $ownerId, $id]);
|
|
|
|
|
|
}
|
2026-09-30 13:24:26 +08:00
|
|
|
|
if ($up->rowCount() === 0) {
|
2026-10-19 17:27:37 +08:00
|
|
|
|
// ⚠️ rowCount()===0 有两种可能:找不到(不可能,上面刚查过)与
|
|
|
|
|
|
// **改的值和原来一模一样**(MySQL 对无变化的 UPDATE 不计数)。
|
|
|
|
|
|
// 后者不是错误 —— 用户点了「完成」但什么都没改,不该报"找不到"。
|
|
|
|
|
|
return ['ok' => true];
|
2026-09-30 11:06:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
return ['ok' => true];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-30 13:24:26 +08:00
|
|
|
|
* 删除一个分组。名册里挂在这个分组下的人**不会消失**,只是变成「未分类」——
|
|
|
|
|
|
* 名册上会多出一个「未分类」栏提醒去归类。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 最后一个**公共**分组不给删:全删光就没有任何分类可用了,那是死路。
|
|
|
|
|
|
* 私自分组可以删到零(公共那套还在,不会没得选)。
|
2026-09-30 11:06:22 +08:00
|
|
|
|
*
|
|
|
|
|
|
* @return array{ok:bool,error?:string,moved?:int}
|
|
|
|
|
|
*/
|
2026-09-30 13:24:26 +08:00
|
|
|
|
public static function delete(int $familyId, int $id, int $ownerId): array
|
2026-09-30 11:06:22 +08:00
|
|
|
|
{
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$ownerId = max(0, $ownerId);
|
|
|
|
|
|
$all = self::forOwner($familyId, $ownerId);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
if (!isset($all[$id])) {
|
2026-09-30 13:24:26 +08:00
|
|
|
|
return ['ok' => false, 'error' => '找不到这个分组(可能不是你那一套里的)。'];
|
2026-09-30 11:06:22 +08:00
|
|
|
|
}
|
2026-09-30 13:24:26 +08:00
|
|
|
|
if ($ownerId === self::PUBLIC_OWNER && count($all) <= 1) {
|
|
|
|
|
|
return ['ok' => false, 'error' => '至少要留一个公共分组 —— 全删光就没法给成员归类了。'];
|
2026-09-30 11:06:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
$db = Database::get();
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$st = $db->prepare('SELECT COUNT(*) FROM person_roster WHERE group_id = ?');
|
|
|
|
|
|
$st->execute([$id]);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
$moved = (int)$st->fetchColumn();
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// ⚠️ 释放范围是**所有**名册条目(不限 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]);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
|
|
|
|
|
|
// 各人自定义顺序里可能留着这个 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));
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$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']);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
/** 按名字找分组 id(找不到返回 0)。默认在**公共**分组里找。 */
|
|
|
|
|
|
public static function idByName(int $familyId, string $name, int $ownerId = self::PUBLIC_OWNER): int
|
2026-09-30 11:06:22 +08:00
|
|
|
|
{
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$st = Database::get()->prepare(
|
|
|
|
|
|
'SELECT id FROM person_groups WHERE family_id = ? AND owner_id = ? AND name = ?'
|
|
|
|
|
|
);
|
|
|
|
|
|
$st->execute([$familyId, max(0, $ownerId), $name]);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
return (int)($st->fetchColumn() ?: 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 某个分组下有多少人 —— **从我自己的名册里数**。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 这是 v5 的关键语义变化:老版本数的是"全家名册里 group_id = 它的有几个人",
|
|
|
|
|
|
* 那种数字对"我自己该怎么归类"没有指导意义(别人把人归到哪一组关我什么事)。
|
|
|
|
|
|
* 现在数的是"我名册里归到这一组的有几个",用来给个人设置里的分组列表做提示。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function counts(int $familyId, int $ownerId, string $kind = 'member'): array
|
2026-09-30 11:06:22 +08:00
|
|
|
|
{
|
|
|
|
|
|
$st = Database::get()->prepare(
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'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)'
|
2026-09-30 11:06:22 +08:00
|
|
|
|
);
|
2026-09-30 13:24:26 +08:00
|
|
|
|
$st->execute([$familyId, $ownerId, $kind]);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
$out = [];
|
|
|
|
|
|
foreach ($st->fetchAll() as $r) {
|
|
|
|
|
|
$out[(int)$r['g']] = (int)$r['n'];
|
|
|
|
|
|
}
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|