Files
FamilyBook/app/PersonGroup.php
T
laoyang 2f7dc6ca23 v63:卡片配色 / 日历本靠右 / 地图题图 / 设置页清理 / 公共标签颜色 / 令牌下线
- 卡片右上角按钮改用「来自哪个模块」的主题色:HomeCards 登记表新增 mod 字段
  与 module(),首页从登记表取色(原来是模板里硬编码 in_array(['agenda','tasks'])
  白名单,新加带按钮的卡片会默默变灰)
- 首页日程把日历本放到色块右侧:.ag-ev 改弹性行,「时间+标题」包进
  .ag-ev-main(flex:1 1 auto; min-width:0),日历本作右侧兄弟。
  原来 display:block 会把日历本顶到第二行,色块一矮就完全看不见
- 旅程地图显示题图:服务端把 media_url(cover_key) 塞进地图数据,
  地图卡片左侧 40x40 题图(object-fit:cover),没有题图不留灰框
- 系统设置第一块改叫「系统标语」;删掉「首页上那一行小字」提示、
  「不是全家的统一口号」说明、「上次结构升级有步骤失败了」整框;
  运行环境删「排错时看这一块」与「亲属连线」那一行(功能与数据未动)
- 公共标签:说明文字全删;列表与编辑合并成一行(色块+名字+N人+箭头+编辑);
  person_groups 加 color 列(Schema addColumn,老库可升);点「编辑」弹框
  改名字与颜色、点「完成」提交;下面那个独立编辑列表撤掉。删除挪进弹框
- 个人设置删掉「手动签发接口令牌」整块;settings/api_token 路由保留但挡下提交
  (只删界面会留下知道地址就能用的后门);令牌只由登录后自动签发,
  设备管理卡保留。apiTokens/apiPlain/apiLabel/apiBase 四个变量一并撤掉
2026-10-19 17:27:37 +08:00

418 lines
18 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;
/**
* 🔴 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];
}
/** 预置分组的来源在 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,
string $color = ''): 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();
// 🔴 v63:颜色可空 —— 空串存 NULL(= "还没选过",见 swatch() 的说明)。
// 传了颜色就按白名单归一(非法值退回空 = 也当没选过,不写脏数据进库)。
$col = trim($color) === '' ? null : self::sanitizeColor($color, '');
$db->prepare(
'INSERT INTO person_groups (family_id, owner_id, name, color, position, is_builtin)
VALUES (?, ?, ?, ?, ?, 0)'
)->execute([$familyId, $ownerId, $name, $col, max($max + 10, 80)]);
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
}
/**
* 改名。$ownerId 用来确认"你改的是你自己那一套里的分组"——
* 公共分组只有管理员能改(调用方把关),别人的私自分组连管理员也不该动。
*
* 🔴 v63:顺手支持改颜色(`$color === null` = **不动颜色**)。
* ⚠️ 用 null 而不是空串表示"不改":空串是"清掉颜色"这个合法意图
* (退回按 id 取默认色),两者必须能区分 —— 这是"部分更新"接口
* 最容易写错的一处(传不传那个字段,语义完全不同)。
* @return array{ok:bool,error?:string}
*/
public static function rename(int $familyId, int $id, string $name, int $ownerId,
?string $color = null): 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 . '」的分组了。'];
}
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]);
}
if ($up->rowCount() === 0) {
// ⚠️ rowCount()===0 有两种可能:找不到(不可能,上面刚查过)与
// **改的值和原来一模一样**(MySQL 对无变化的 UPDATE 不计数)。
// 后者不是错误 —— 用户点了「完成」但什么都没改,不该报"找不到"。
return ['ok' => true];
}
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;
}
}