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

按用户十点反馈逐条落地(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
+24 -1
View File
@@ -26,7 +26,30 @@ final class Account
'INSERT INTO accounts (person_id, password_hash, role) VALUES (?, ?, ?)' 'INSERT INTO accounts (person_id, password_hash, role) VALUES (?, ?, ?)'
); );
$st->execute([$personId, password_hash($password, PASSWORD_DEFAULT), $role]); $st->execute([$personId, password_hash($password, PASSWORD_DEFAULT), $role]);
return (int)Database::get()->lastInsertId(); $id = (int)Database::get()->lastInsertId();
self::seedSelfRoster($personId);
return $id;
}
/**
* 刚开通账号的人,自己的名册里先放上他自己。
*
* 名册是「每人一份」(v5 起),所以新账号的名册默认是空的 ——
* 那是对的(他再加/再复用别人),但连「我」都看不见就太奇怪了:
* 首页/家谱里都有他自己,名册里却没有,看着像自己不在家里。
* 所以这里只补他自己,别人一概不补。
*
* ⚠️ 故意**不** try/catch 吞异常:这是开账号链路的一部分,
* 出了问题要立刻看得见(静默失败正是这个项目踩过的坑)。
*/
private static function seedSelfRoster(int $personId): void
{
$st = Database::get()->prepare('SELECT family_id FROM persons WHERE id = ?');
$st->execute([$personId]);
$fid = (int)$st->fetchColumn();
if ($fid > 0) {
PersonRoster::ensure($fid, $personId, $personId, null, 'self');
}
} }
public static function setPassword(int $accountId, string $password): void public static function setPassword(int $accountId, string $password): void
+1 -1
View File
@@ -68,7 +68,7 @@ final class Auth
/** /**
* 首页标题 —— 「以我为中心」最直接的一处落点。 * 首页标题 —— 「以我为中心」最直接的一处落点。
* 登录后看到的不是「家书」,而是「老杨的家书」。 * 登录后看到的不是「家书」,而是「<我的名字>的家书」。
*/ */
public static function homeTitle(): string public static function homeTitle(): string
{ {
+1 -1
View File
@@ -149,7 +149,7 @@ final class Nickname
'SELECT n.target_id, n.nickname, p.name, p.avatar, p.is_placeholder 'SELECT n.target_id, n.nickname, p.name, p.avatar, p.is_placeholder
FROM nicknames n JOIN persons p ON p.id = n.target_id FROM nicknames n JOIN persons p ON p.id = n.target_id
WHERE n.viewer_id = ? AND n.family_id = ? WHERE n.viewer_id = ? AND n.family_id = ?
ORDER BY p.sort_order ASC, p.id ASC' ORDER BY p.id ASC'
); );
$st->execute([$viewerId, $familyId]); $st->execute([$viewerId, $familyId]);
return $st->fetchAll(); return $st->fetchAll();
+48 -26
View File
@@ -41,7 +41,44 @@ final class Person
$args[] = $like; $args[] = $like;
$args[] = $like; $args[] = $like;
} }
$sql .= ' ORDER BY p.sort_order ASC, p.id ASC'; $sql .= ' ORDER BY p.id ASC';
$st = Database::get()->prepare($sql);
$st->execute($args);
return $st->fetchAll();
}
/**
* **我的名册**:只列我自己的名册里的家庭成员(带分组与顺序)。
*
* 与 all() 的分工(v5 起两者不再是一回事):
* · all() —— 全家庭的成员,不管是谁的名册(首页徽标、邀请访客等内部用途);
* · roster() —— 名册页该显示的那些:我加的人 + 别人分享给我的,顺序是我的。
*
* 分组与顺序都从 person_roster 取(列名故意与老库的 persons.group_id /
* persons.sort_order 保持一致,调用方几乎不用改)。
*/
public static function roster(int $familyId, int $viewerId, string $kind = 'member', string $q = ''): array
{
$sql = 'SELECT p.*, r.group_id, r.sort_order, r.source AS roster_source,
a.id AS account_id, a.role AS account_role, a.status AS account_status,
a.last_login_at, n.nickname AS my_nickname
FROM person_roster r
JOIN persons p ON p.id = r.person_id
LEFT JOIN accounts a ON a.person_id = p.id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?';
$args = [$viewerId];
$sql .= ' WHERE r.family_id = ? AND r.owner_id = ? AND p.kind = ?';
$args[] = $familyId;
$args[] = $viewerId;
$args[] = $kind;
if ($q !== '') {
$sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
$like = '%' . $q . '%';
$args[] = $like;
$args[] = $like;
$args[] = $like;
}
$sql .= ' ORDER BY r.sort_order ASC, p.id ASC';
$st = Database::get()->prepare($sql); $st = Database::get()->prepare($sql);
$st->execute($args); $st->execute($args);
return $st->fetchAll(); return $st->fetchAll();
@@ -91,8 +128,8 @@ final class Person
(family_id, kind, name, gender, birth_rank, birthday, (family_id, kind, name, gender, birth_rank, birthday,
birthday_id_card, birthday_lunar, birthday_lunar_leap, slogan, avatar, birthday_id_card, birthday_lunar, birthday_lunar_leap, slogan, avatar,
phone_hash, phone_encrypted, id_card_encrypted, email, address, note, phone_hash, phone_encrypted, id_card_encrypted, email, address, note,
is_placeholder, group_id, sort_order) is_placeholder)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'; VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
$st = Database::get()->prepare($sql); $st = Database::get()->prepare($sql);
$st->execute([ $st->execute([
$familyId, $familyId,
@@ -113,8 +150,6 @@ final class Person
($d['address'] ?? '') !== '' ? $d['address'] : null, ($d['address'] ?? '') !== '' ? $d['address'] : null,
($d['note'] ?? '') !== '' ? $d['note'] : null, ($d['note'] ?? '') !== '' ? $d['note'] : null,
!empty($d['is_placeholder']) ? 1 : 0, !empty($d['is_placeholder']) ? 1 : 0,
!empty($d['group_id']) ? (int)$d['group_id'] : null,
(int)($d['sort_order'] ?? 1000),
]); ]);
return (int)Database::get()->lastInsertId(); return (int)Database::get()->lastInsertId();
} }
@@ -127,7 +162,7 @@ final class Person
name = ?, gender = ?, birth_rank = ?, birthday = ?, name = ?, gender = ?, birth_rank = ?, birthday = ?,
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?, birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?, phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?,
email = ?, address = ?, note = ?, is_placeholder = ?, group_id = ?, sort_order = ? email = ?, address = ?, note = ?, is_placeholder = ?
WHERE id = ?'; WHERE id = ?';
Database::get()->prepare($sql)->execute([ Database::get()->prepare($sql)->execute([
$d['name'], $d['name'],
@@ -146,32 +181,19 @@ final class Person
($d['address'] ?? '') !== '' ? $d['address'] : null, ($d['address'] ?? '') !== '' ? $d['address'] : null,
($d['note'] ?? '') !== '' ? $d['note'] : null, ($d['note'] ?? '') !== '' ? $d['note'] : null,
!empty($d['is_placeholder']) ? 1 : 0, !empty($d['is_placeholder']) ? 1 : 0,
// ⚠️ 这里不能用 `!empty($d['group_id'])` 之外的花样:
// "清空分组"(改成未分类)也是一个合法意图,所以 0/'' 一律落成 NULL。
!empty($d['group_id']) ? (int)$d['group_id'] : null,
(int)($d['sort_order'] ?? 1000),
$id, $id,
]); ]);
} }
/** /**
* 只改「这个人属于哪一组」(名册上的快捷归类用)。 * 自助改自己的资料。
* *
* 单独一个方法,是为了让名册那一小块表单**不必**把整条记录回传 —— * 与上面的 update() 分开是有意的:那个是"能写的人编名册",字段全、权限高;
* 否则一次归类提交就要带上姓名/生日/身份证号,任何一处没带全就会把数据改坏。
*/
public static function setGroup(int $id, ?int $groupId): void
{
Database::get()->prepare('UPDATE persons SET group_id = ? WHERE id = ?')
->execute([$groupId ?: null, $id]);
}
/**
* 自助改自己的资料(个人信息页用)。
*
* 与上面的 update() 分开是有意的:那个是"管理员编名册",字段全、权限高;
* 这个只允许改**属于自己的**东西 —— 姓名、性别、三个生日、一句话、头像、联系方式。 * 这个只允许改**属于自己的**东西 —— 姓名、性别、三个生日、一句话、头像、联系方式。
* 排行、排序号、虚拟人物标记、以及身份证号(会牵动身份证生日)都只能由管理员改名册时动。 * 排行、虚拟人物标记、以及身份证号(会牵动身份证生日)都只能由管理员改名册时动。
*
* ⚠️ v5 起这里**不再有**分组:分组是「我和他的关系」,挂在 person_roster 上,
* 只能在名册里拖或者在那张卡片的「调整分组」里改(见 PersonRoster::assignGroup)。
*/ */
public static function updateSelf(int $id, array $d): void public static function updateSelf(int $id, array $d): void
{ {
@@ -230,7 +252,7 @@ final class Person
FROM persons p FROM persons p
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE p.family_id = ? AND p.kind = "member" AND p.id <> ? WHERE p.family_id = ? AND p.kind = "member" AND p.id <> ?
ORDER BY p.sort_order ASC, p.id ASC' ORDER BY p.id ASC'
); );
$st->execute([$viewerId, $familyId, $exceptId]); $st->execute([$viewerId, $familyId, $exceptId]);
return $st->fetchAll(); return $st->fetchAll();
+159 -89
View File
@@ -1,17 +1,18 @@
<?php <?php
/** /**
* 关系分组:一个人属于哪一组(家人 / 亲戚 / 同事 / 朋友 …),名册按它分组展示。 * 关系分组:一个人属于哪一组(家人 / 亲戚 / 同事 / 朋友 …),名册按它分栏展示。
* *
* 三条刻意的设计: * 四条刻意的设计(第一、二条是被纠正过之后定下来的):
* ① **分类是全家共用的,顺序是各人各定的。** * ① **一套是公共的,一套是我自己的。** `owner_id = 0` 是管理员在「系统设置」
* 老杨的同事,对全家来说都是同事 —— 所以分类只存一份(persons.group_id)。 * 里维护的公共分组(全家都能用);`owner_id = 我` 是我在「个人设置」里加的,
* 但「哪一组排前面」纯属个人习惯,存在 user_settings.group_order 里, * 只有我自己看得见。所以「某人的同事」如果全家都认,管理员建公共的;
* 每个人可以不一样(爸爸把「家人」排第一,妈妈把「亲戚」排第一)。 * 只有我觉得该有「球友」,我自己加一个,不影响别人。
* ② **分类可以由用户自己加。** 所以是表而不是 ENUM —— 预置的七个只是初始值, * ② **顺序是各人各定的。** 「哪一组排前面」纯属个人习惯,存在
* 觉得不够就自己加,加错了可以改名或删掉。 * user_settings.group_order 里(每人一份),改它不会动到公共的默认顺序。
* ③ **不用外键约束 group_id。** 老库升级只能 ADD COLUMN,加不上外键, * ③ **分类可以由用户自己加。** 所以是表而不是 ENUM —— 预置的七个只是初始值。
* ④ **不用外键约束 person_roster.group_id。** 老库升级只能 ADD COLUMN、加不上外键,
* 新装的又加了就会两边结构不一致;索性统一由程序保证 —— * 新装的又加了就会两边结构不一致;索性统一由程序保证 ——
* 删除分组时把挂靠的人置为「未分类」(NULL),不留悬空 id。 * 删除分组时把挂靠的名册条目置为「未分类」(NULL),不留悬空 id。
*/ */
declare(strict_types=1); declare(strict_types=1);
@@ -20,27 +21,46 @@ namespace App;
final class PersonGroup final class PersonGroup
{ {
/** 公共分组(管理员维护,全家共用)的 owner_id */
public const PUBLIC_OWNER = 0;
/** 预置分组的来源在 Schema::DEFAULT_GROUPS(安装与升级共用一份,避免两处漂移) */ /** 预置分组的来源在 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) { if ($familyId <= 0) {
return; return;
} }
$st = Database::get()->prepare( $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) { 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 public static function byId(int $familyId): array
{ {
$st = Database::get()->prepare( $st = Database::get()->prepare(
@@ -54,6 +74,34 @@ final class PersonGroup
return $out; 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 public static function orderFor(int $familyId, int $viewerId = 0): array
{ {
$rest = self::byId($familyId); $rest = self::visible($familyId, $viewerId);
if (!$rest) { if (!$rest) {
return []; return [];
} }
@@ -90,7 +138,7 @@ final class PersonGroup
/** 保存「我」的分组顺序(只影响我自己) */ /** 保存「我」的分组顺序(只影响我自己) */
public static function saveOrder(int $familyId, int $viewerId, array $ids): void public static function saveOrder(int $familyId, int $viewerId, array $ids): void
{ {
$valid = self::byId($familyId); $valid = self::visible($familyId, $viewerId);
$clean = []; $clean = [];
foreach ($ids as $id) { foreach ($ids as $id) {
$id = (int)$id; $id = (int)$id;
@@ -107,6 +155,20 @@ final class PersonGroup
UserSetting::set($viewerId, 'group_order', json_encode($clean, JSON_UNESCAPED_UNICODE)); 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 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} * @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); $name = trim($name);
$bad = self::validateName($name); $bad = self::validateName($name);
if ($bad !== '') { if ($bad !== '') {
return ['ok' => false, 'error' => $bad]; return ['ok' => false, 'error' => $bad];
} }
$st = Database::get()->prepare( $db = Database::get();
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND name = ?' $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) { if ((int)$st->fetchColumn() > 0) {
return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。']; return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。'];
} }
// ⚠️ 别写成 `$max = (int)$db->prepare(...)->execute([...])`: // ⚠️ 别写成 `$max = (int)$db->prepare(...)->execute([...])`:
// PDOStatement::execute() 返回 bool,强转永远是 1 —— 位置会算错。 // PDOStatement::execute() 返回 bool,强转永远是 1 —— 位置会算错。
$st2 = Database::get()->prepare('SELECT COALESCE(MAX(position), 0) FROM person_groups WHERE family_id = ?'); $st2 = $db->prepare(
$st2->execute([$familyId]); 'SELECT COALESCE(MAX(position), 0) FROM person_groups WHERE family_id = ? AND owner_id = ?'
);
$st2->execute([$familyId, $ownerId]);
$max = (int)$st2->fetchColumn(); $max = (int)$st2->fetchColumn();
Database::get()->prepare( $db->prepare(
'INSERT INTO person_groups (family_id, name, position, is_builtin) VALUES (?, ?, ?, 0)' 'INSERT INTO person_groups (family_id, owner_id, name, position, is_builtin)
)->execute([$familyId, $name, max($max + 10, 80)]); VALUES (?, ?, ?, ?, 0)'
return ['ok' => true, 'id' => (int)Database::get()->lastInsertId()]; )->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); $name = trim($name);
$bad = self::validateName($name); $bad = self::validateName($name);
if ($bad !== '') { if ($bad !== '') {
return ['ok' => false, 'error' => $bad]; return ['ok' => false, 'error' => $bad];
} }
$st = Database::get()->prepare( $db = Database::get();
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND name = ? AND id <> ?' $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) { if ((int)$st->fetchColumn() > 0) {
return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。']; return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。'];
} }
$st = Database::get()->prepare('UPDATE person_groups SET name = ? WHERE family_id = ? AND id = ?'); $up = $db->prepare(
$st->execute([$name, $familyId, $id]); 'UPDATE person_groups SET name = ? WHERE family_id = ? AND owner_id = ? AND id = ?'
if ($st->rowCount() === 0) { );
$up->execute([$name, $familyId, $ownerId, $id]);
if ($up->rowCount() === 0) {
return ['ok' => false, 'error' => '找不到这个分组。']; return ['ok' => false, 'error' => '找不到这个分组。'];
} }
return ['ok' => true]; return ['ok' => true];
} }
/** /**
* 删除一个分组。挂在这个分组下的人**不会消失**,只是变成「未分类」—— * 删除一个分组。名册里挂在这个分组下的人**不会消失**,只是变成「未分类」——
* 名册上会多出一个「未分类」组提醒去归类。 * 名册上会多出一个「未分类」栏提醒去归类。
* 最后一个分组不给删:全删光就没有任何分类可用了,那是死路。 *
* 最后一个**公共**分组不给删:全删光就没有任何分类可用了,那是死路。
* 私自分组可以删到零(公共那套还在,不会没得选)。
* *
* @return array{ok:bool,error?:string,moved?:int} * @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])) { if (!isset($all[$id])) {
return ['ok' => false, 'error' => '找不到这个分组。']; return ['ok' => false, 'error' => '找不到这个分组(可能不是你那一套里的)。'];
} }
if (count($all) <= 1) { if ($ownerId === self::PUBLIC_OWNER && count($all) <= 1) {
return ['ok' => false, 'error' => '至少要留一个分组 —— 全删光就没法给成员归类了。']; return ['ok' => false, 'error' => '至少要留一个公共分组 —— 全删光就没法给成员归类了。'];
} }
$db = Database::get(); $db = Database::get();
$st = $db->prepare('SELECT COUNT(*) FROM persons WHERE family_id = ? AND group_id = ?'); $st = $db->prepare('SELECT COUNT(*) FROM person_roster WHERE group_id = ?');
$st->execute([$familyId, $id]); $st->execute([$id]);
$moved = (int)$st->fetchColumn(); $moved = (int)$st->fetchColumn();
$db->prepare('UPDATE persons SET group_id = NULL WHERE family_id = ? AND group_id = ?') // ⚠️ 释放范围是**所有**名册条目(不限 owner):公共分组人人都能用,
->execute([$familyId, $id]); // 只清自己那几条会让别人名册里留下指向已删分组的 id(那一栏就永远空着、
$db->prepare('DELETE FROM person_groups WHERE family_id = ? AND id = ?') // 而对应的卡片在同一次渲染里彻底消失)。
->execute([$familyId, $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,清掉免得越积越脏 // 各人自定义顺序里可能留着这个 id,清掉免得越积越脏
self::pruneOrder($familyId, $id); self::pruneOrder($familyId, $id);
@@ -229,56 +317,38 @@ final class PersonGroup
continue; continue;
} }
$keep = array_values(array_filter($ids, fn($x) => (int)$x !== $removedId)); $keep = array_values(array_filter($ids, fn($x) => (int)$x !== $removedId));
$db->prepare('UPDATE user_settings SET v = ? WHERE person_id = ? AND k = ?') $prep = $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->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
* 家庭成员 → 家人;外部联系人 → 认识。
* 只补 NULL,用户自己改过的一律不动(幂等)。
*/
public static function backfillMissing(\PDO $db): void
{ {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { $st = Database::get()->prepare(
$fid = (int)$fam['id']; 'SELECT id FROM person_groups WHERE family_id = ? AND owner_id = ? AND name = ?'
// 家庭可能还没套用预置分组(ensure 的 seed 在表建好之后才跑) );
$st = $db->prepare('SELECT COUNT(*) FROM person_groups WHERE family_id = ?'); $st->execute([$familyId, max(0, $ownerId), $name]);
$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); 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( $st = Database::get()->prepare(
'SELECT COALESCE(group_id, 0) AS g, COUNT(*) AS n 'SELECT COALESCE(r.group_id, 0) AS g, COUNT(*) AS n
FROM persons WHERE family_id = ? AND kind = ? FROM person_roster r
GROUP BY COALESCE(group_id, 0)' 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 = []; $out = [];
foreach ($st->fetchAll() as $r) { foreach ($st->fetchAll() as $r) {
$out[(int)$r['g']] = (int)$r['n']; $out[(int)$r['g']] = (int)$r['n'];
+311
View File
@@ -0,0 +1,311 @@
<?php
/**
* 每人一份名册。
*
* 老模型是「一份全家共用的名册」:`persons.group_id` 决定归类、`persons.sort_order`
* 决定顺序 —— 于是妈妈想把「亲戚」排前面,就得把全家人的视图一起改掉。
*
* 新模型把「**我和他的关系**」单独拿出来存:
* · `person_roster(owner_id, person_id, group_id, sort_order, source)`
* · owner 是我的名册,person 是躺在我名册里的人,group 是我把他归到哪一组,
* sort 是在**我的名册里**的先后。
* 同一个「张伟」,在我的名册里是「同事」、在妈妈的名册里是「亲戚」,互不打扰。
*
* 谁看得到谁(这是"每人一份"的核心):
* · 我自己加的人(source=self)
* · 别人分享给我的名片(source=shared)
* · 升级时按当时"人人在自己名册里都能看到全家"的现状补的(source=seeded)
* 除此之外一概看不见。所以「添加成员」时若这个人系统里已经有了,
* 要引导用户走「从家里已有的人里挑」(见 pickable()),而不是又录一遍。
*
* ⚠️ 与 `persons.kind` 的关系:roster 只管"看不看得见",
* 家谱(Kinship)仍然按全家庭的 persons + relations 推导 ——
* 名册是通讯录,家谱是血缘图,两者故意的不是一回事。
*/
declare(strict_types=1);
namespace App;
final class PersonRoster
{
/** 名册里一条记录的来源 */
public const SOURCES = ['self', 'shared', 'seeded'];
/**
* 把某人加进某人的名册(幂等:已经在里面就什么都不做)。
*
* @param string $source self=我加的 / shared=别人分享的 / seeded=升级补的
* @return bool 真的插入了才返回 true
*/
public static function ensure(
int $familyId,
int $ownerId,
int $personId,
?int $groupId = null,
string $source = 'self'
): bool {
if ($familyId <= 0 || $ownerId <= 0 || $personId <= 0) {
return false;
}
if (!in_array($source, self::SOURCES, true)) {
$source = 'self';
}
$db = Database::get();
$st = $db->prepare('SELECT COUNT(*) FROM person_roster WHERE owner_id = ? AND person_id = ?');
$st->execute([$ownerId, $personId]);
if ((int)$st->fetchColumn() > 0) {
return false;
}
// 分组必须是**这个人自己能用**的(公共的 + 他自己的)——
// 传进来一个别人家的私自分组就落成未分类,绝不静默挂错
// (挂上去的后果是:那一栏永远显示不出来他,而他在别的栏里也不见了)。
if ($groupId !== null && $groupId > 0
&& !isset(PersonGroup::visibleIds($familyId, $ownerId)[$groupId])) {
$groupId = null;
}
// ⚠️ 新条目排在末尾:不是取 COUNT(*),而是取当前最大 sort_order + 10 ——
// 中间被拖走/删掉的人会在序号里留下空档,用 COUNT 会插到别人前面去。
$st2 = $db->prepare('SELECT COALESCE(MAX(sort_order), 0) FROM person_roster WHERE owner_id = ?');
$st2->execute([$ownerId]);
$next = (int)$st2->fetchColumn() + 10;
$db->prepare(
'INSERT INTO person_roster (family_id, owner_id, person_id, group_id, sort_order, source)
VALUES (?, ?, ?, ?, ?, ?)'
)->execute([$familyId, $ownerId, $personId, $groupId ?: null, $next, $source]);
return true;
}
/**
* 我的名册:person_id => ['group_id' => ?int, 'sort_order' => int, 'source' => string]
*/
public static function mine(int $familyId, int $ownerId): array
{
if ($ownerId <= 0) {
return [];
}
$st = Database::get()->prepare(
'SELECT person_id, group_id, sort_order, source FROM person_roster
WHERE family_id = ? AND owner_id = ?
ORDER BY sort_order ASC, person_id ASC'
);
$st->execute([$familyId, $ownerId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['person_id']] = [
'group_id' => $r['group_id'] === null ? null : (int)$r['group_id'],
'sort_order' => (int)$r['sort_order'],
'source' => (string)$r['source'],
];
}
return $out;
}
/** 只取 id 列表(判断"看不看得见"时用) */
public static function visibleIds(int $familyId, int $ownerId): array
{
return array_keys(self::mine($familyId, $ownerId));
}
public static function countFor(int $familyId, int $ownerId): int
{
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM person_roster WHERE family_id = ? AND owner_id = ?'
);
$st->execute([$familyId, $ownerId]);
return (int)$st->fetchColumn();
}
/**
* 把「我名册里的某个人」改到一个分组(名册卡片上的快捷归类 / 拖动改栏都用它)。
*
* $groupId 必须是**我能用的**分组(公共的 + 我自己的),否则会被拒绝 ——
* 不然可以把人挂到别人家的分组上,那一栏就永远显示不出来他。
*/
public static function assignGroup(int $familyId, int $ownerId, int $personId, ?int $groupId): bool
{
if ($groupId !== null && $groupId > 0
&& !isset(PersonGroup::visibleIds($familyId, $ownerId)[$groupId])) {
return false;
}
$st = Database::get()->prepare(
'UPDATE person_roster SET group_id = ?
WHERE family_id = ? AND owner_id = ? AND person_id = ?'
);
$st->execute([$groupId ?: null, $familyId, $ownerId, $personId]);
return $st->rowCount() > 0;
}
/**
* 一次性把「每一栏有哪些人、什么顺序」落库 —— 拖动卡片排序/换栏之后调它。
*
* 为什么整版提交而不是"移动一条算一次":
* 拖拽的结果是**一组相对顺序**,逐条提交会在中途留下一个自相矛盾的中间态
* (比如两个人暂时同号),刷新一下就变样。整版提交 = 一次事务、一个确定结果。
*
* @param array $columns [['g' => 分组id(0=未分类), 'ids' => [personId, ...]], ...]
* @return int 实际写入的条数
*/
public static function applyLayout(int $familyId, int $ownerId, array $columns): int
{
$mine = self::mine($familyId, $ownerId);
if (!$mine) {
return 0;
}
$okGroups = PersonGroup::visibleIds($familyId, $ownerId);
$db = Database::get();
$up = $db->prepare(
'UPDATE person_roster SET group_id = ?, sort_order = ?
WHERE family_id = ? AND owner_id = ? AND person_id = ?'
);
$written = 0;
$sort = 0;
foreach ($columns as $col) {
if (!is_array($col)) {
continue;
}
$gid = (int)($col['g'] ?? 0);
// 分组不合法(别人家的私自分组 / 已删掉的分组)→ 落成未分类,绝不静默挂错
if ($gid !== 0 && !isset($okGroups[$gid])) {
$gid = 0;
}
foreach ((array)($col['ids'] ?? []) as $pid) {
$pid = (int)$pid;
if ($pid <= 0 || !isset($mine[$pid])) {
continue; // 不在我名册里的人,忽略
}
$sort += 10;
$up->execute([$gid ?: null, $sort, $familyId, $ownerId, $pid]);
$written++;
}
}
return $written;
}
/** 把一条(或几条)名片分享到别人的名册里。返回真的新增了几条。 */
public static function share(int $familyId, int $toOwnerId, array $personIds, string $source = 'shared'): int
{
$n = 0;
foreach ($personIds as $pid) {
if (self::ensure($familyId, $toOwnerId, (int)$pid, null, $source)) {
$n++;
}
}
return $n;
}
/** 从我的名册里移掉一个人(只是我不再看到他,人还在名册里、还在家谱上) */
public static function remove(int $familyId, int $ownerId, int $personId): bool
{
$st = Database::get()->prepare(
'DELETE FROM person_roster WHERE family_id = ? AND owner_id = ? AND person_id = ?'
);
$st->execute([$familyId, $ownerId, $personId]);
return $st->rowCount() > 0;
}
/**
* 系统里已经有的人 —— 但不在我的名册里。
*
* 「添加成员」的第二个入口就靠它:同一个人不该被录两遍
* (录两遍会变成两条 persons,账号、亲属关系、生日全都各存一份)。
*
* @return array 每行含 id/name/gender/avatar + 已有账号信息(好让人认得出是谁)
*/
public static function pickable(int $familyId, int $ownerId, string $kind = 'member'): array
{
$st = Database::get()->prepare(
'SELECT p.id, p.name, p.gender, p.avatar, p.is_placeholder,
a.role AS account_role, a.status AS account_status,
n.nickname AS my_nickname
FROM persons p
LEFT JOIN accounts a ON a.person_id = p.id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE p.family_id = ? AND p.kind = ?
AND p.id NOT IN (SELECT person_id FROM person_roster WHERE owner_id = ? AND family_id = ?)
ORDER BY p.id ASC'
);
$st->execute([$ownerId, $familyId, $kind, $ownerId, $familyId]);
return $st->fetchAll();
}
/**
* 结构升级专用:把老库「人人在自己名册里都能看到全家」的现状原样搬过来。
*
* ⚠️ 这一步必须在**删 persons.group_id 之前**跑完,而且**不能容错**:
* 它是删列的唯一前提,半途失败还继续删 = 直接丢数据(见 Schema::ensure)。
*
* 谁的名册要补:**有账号的人**(没账号的人永远登不进来,给他补名册只是一堆死数据)。
* 补谁进去:家庭全部成员(kind='member')。这正是老代码 `Person::all(...)` 的范围,
* 所以升级前后每个人看到的名册**一模一样**,不会有人觉得"人丢了"。
* 老库里的 group_id / sort_order 也一并搬——分类语义从"人的属性"变成"我和他的关系",
* 但**落在每个人名册里的值不变**,所以看起来完全一样。
*
* 幂等:靠 uk_roster_owner_person 唯一键 + INSERT IGNORE,重复跑不会插重。
*/
public static function seedFromPersons(\PDO $db): void
{
$hasGroup = self::hasColumn($db, 'persons', 'group_id');
$hasSort = self::hasColumn($db, 'persons', 'sort_order');
$oldGroup = $hasGroup ? 'p.group_id' : 'NULL';
$oldSort = $hasSort ? 'p.sort_order' : 'p.id';
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
$fid = (int)$fam['id'];
$members = $db->prepare(
"SELECT p.id, {$oldGroup} AS g, {$oldSort} AS s
FROM persons p
WHERE p.family_id = ? AND p.kind = 'member'
ORDER BY p.id ASC"
);
$members->execute([$fid]);
$rows = $members->fetchAll();
if (!$rows) {
continue;
}
// 有账号的人 = 可能登进来的人
$owners = $db->prepare(
'SELECT a.person_id FROM accounts a
JOIN persons p ON p.id = a.person_id
WHERE p.family_id = ?'
);
$owners->execute([$fid]);
// ⚠️ 必须在这里一次取完:PDOStatement::fetchAll() 取的是"剩余行",
// 放进下面的循环里调第二次就会是空数组 —— 于是第一个人之后
// 谁的名册都补不上,而且不报任何错(线上表现是"升级完只有我自己的名册有人")。
$ownerIds = array_map(static fn($o) => (int)$o['person_id'], $owners->fetchAll());
$ins = $db->prepare(
'INSERT IGNORE INTO person_roster
(family_id, owner_id, person_id, group_id, sort_order, source)
VALUES (?, ?, ?, ?, ?, ?)'
);
$seq = 0;
foreach ($rows as $r) {
$seq += 10;
$pid = (int)$r['id'];
$gid = ((int)($r['g'] ?? 0)) ?: null;
// 顺序优先沿用老库的 sort_order;重号了就靠位次兜底,保证结果稳定
$sort = (int)($r['s'] ?? 0);
if ($sort <= 0) {
$sort = $seq;
}
foreach ($ownerIds as $oid) {
$ins->execute([$fid, $oid, $pid, $gid, $sort, 'seeded']);
}
}
}
}
/** 判断某列是否存在(升级中途用,Schema 的私有方法不便跨类调用) */
private static function hasColumn(\PDO $db, string $table, string $column): bool
{
$st = $db->prepare(
'SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'
);
$st->execute([$table, $column]);
return (int)$st->fetchColumn() > 0;
}
}
+149 -26
View File
@@ -26,8 +26,11 @@ final class Schema
* 3 → 三个生日(birthday 升 DATETIME + id_card/lunar/leap)、slogan、nicknames 表 * 3 → 三个生日(birthday 升 DATETIME + id_card/lunar/leap)、slogan、nicknames 表
* 4 → person_groups(关系分组,全家统一但可自定义)、persons.group_id、 * 4 → person_groups(关系分组,全家统一但可自定义)、persons.group_id、
* user_settings(「我的」维度设置:分组顺序等) * user_settings(「我的」维度设置:分组顺序等)
* 5 → **名册改成「每人一份」**:person_roster(谁在我的名册里 / 归哪一组 / 排第几)、
* person_groups.owner_id(0=管理员维护的公共分组,否则=某人的私自分组);
* 同时**删掉 persons.group_id**(分组从此是「我和他的关系」,挂在 roster 上)
*/ */
public const VERSION = '4'; public const VERSION = '5';
/** @return string[] */ /** @return string[] */
public static function statements(): array public static function statements(): array
@@ -60,14 +63,11 @@ CREATE TABLE IF NOT EXISTS `persons` (
`address` VARCHAR(200) DEFAULT NULL, `address` VARCHAR(200) DEFAULT NULL,
`note` TEXT DEFAULT NULL COMMENT '备注', `note` TEXT DEFAULT NULL COMMENT '备注',
`is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)', `is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)',
`group_id` INT UNSIGNED DEFAULT NULL COMMENT '关系分组(人属于哪一组:家人/同事/朋友…),全家人共用一套分类',
`sort_order` INT NOT NULL DEFAULT 1000 COMMENT '名册排序,小的在前',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_phone_hash` (`phone_hash`), UNIQUE KEY `uk_phone_hash` (`phone_hash`),
KEY `idx_family_kind` (`family_id`, `kind`, `sort_order`), KEY `idx_family_kind` (`family_id`, `kind`),
KEY `idx_person_group` (`group_id`),
CONSTRAINT `fk_persons_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE CONSTRAINT `fk_persons_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='家庭名册' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='家庭名册'
SQL, SQL,
@@ -132,15 +132,35 @@ SQL,
CREATE TABLE IF NOT EXISTS `person_groups` ( CREATE TABLE IF NOT EXISTS `person_groups` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL, `family_id` INT UNSIGNED NOT NULL,
`owner_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=全家的公共分组(管理员在系统设置里维护);否则=这个人的私自分组,只有他自己看得到',
`name` VARCHAR(20) NOT NULL COMMENT '分组名,如「家人」「同事」', `name` VARCHAR(20) NOT NULL COMMENT '分组名,如「家人」「同事」',
`position` INT NOT NULL DEFAULT 100 COMMENT '默认顺序(用户没自定义顺序时按它排)', `position` INT NOT NULL DEFAULT 100 COMMENT '默认顺序(用户没自定义顺序时按它排)',
`is_builtin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=安装时预置的分组', `is_builtin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=安装时预置的分组',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_group_name` (`family_id`, `name`), UNIQUE KEY `uk_group_owner_name` (`family_id`, `owner_id`, `name`),
KEY `idx_group_family` (`family_id`, `position`), KEY `idx_group_family` (`family_id`, `position`),
CONSTRAINT `fk_group_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE CONSTRAINT `fk_group_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='关系分组:全家人共用一套(一个人只属于一个分组),但每个人可以各自决定分组的先后' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='关系分组:一套公共的(管理员维护)+每人可加只给自己看的分组'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `person_roster` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`owner_id` INT UNSIGNED NOT NULL COMMENT '这是谁的名册(persons.id)',
`person_id` INT UNSIGNED NOT NULL COMMENT '躺在他名册里的人(persons.id)',
`group_id` INT UNSIGNED DEFAULT NULL COMMENT '我把他归到哪一组(公共同我的私自分组都行);NULL=未分类',
`sort_order` INT NOT NULL DEFAULT 1000 COMMENT '**在我的名册里**的先后,拖动卡片改的就是它',
`source` ENUM('self','shared','seeded') NOT NULL DEFAULT 'self' COMMENT 'self=我自己加的 / shared=别人分享给我的 / seeded=升级时按当时的可见范围补的',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_roster_owner_person` (`owner_id`, `person_id`),
KEY `idx_roster_owner_sort` (`owner_id`, `sort_order`),
KEY `idx_roster_person` (`person_id`),
CONSTRAINT `fk_roster_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_roster_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_roster_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='每人一份名册:谁在我的名册里、归到哪一组、排第几 —— 都只影响我自己'
SQL, SQL,
<<<'SQL' <<<'SQL'
CREATE TABLE IF NOT EXISTS `user_settings` ( CREATE TABLE IF NOT EXISTS `user_settings` (
@@ -166,6 +186,37 @@ SQL,
'不熟' => 70, '不熟' => 70,
]; ];
/**
* 结构升级里「数据回填」类步骤的失败记录。
*
* 为什么要把「回填」和「改结构」分开对待:
* 改结构失败 = 代码与库对不上,必须让整站炸出来(看得见才好修);
* 回填失败 = 往往只跟**某一行老数据**有关(比如某条空日期),
* 它不该拦住后面所有结构变更 —— 2026-09-30 就是反过来:
* 一个回填里的 SQL 在 MySQL 严格模式下报错,把 addColumn(group_id)、
* 预置分组、称呼迁移**全部**挡在后面,而异常又被 route 层吞掉,
* 于是「线上看着是好的、其实升级从没跑完」,拖了两轮才发现。
*/
private static array $stepErrors = [];
/** @return string[] 最近一次 ensure() 里被容错吞掉的数据步骤失败 */
public static function stepErrors(): array
{
return self::$stepErrors;
}
private static function tryStep(string $label, callable $fn): void
{
try {
$fn();
} catch (\Throwable $e) {
self::$stepErrors[] = $label . ':' . $e->getMessage();
// log_errors=1 → 落到服务器 PHP 错误日志,别让它只活在内存里
error_log('[familybook] 升级步骤失败(已跳过,其余步骤继续)' . $label
. ':' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
}
}
/** /**
* 幂等结构升级:建表 + 补齐后加的列/枚举值。 * 幂等结构升级:建表 + 补齐后加的列/枚举值。
* *
@@ -176,6 +227,7 @@ SQL,
public static function ensure(): void public static function ensure(): void
{ {
$db = Database::get(); $db = Database::get();
self::$stepErrors = [];
foreach (self::statements() as $sql) { foreach (self::statements() as $sql) {
$db->exec($sql); $db->exec($sql);
} }
@@ -200,9 +252,11 @@ SQL,
"ADD COLUMN `slogan` VARCHAR(200) DEFAULT NULL COMMENT '个人的一句话;没填就用家庭统一的那句' AFTER `note`"); "ADD COLUMN `slogan` VARCHAR(200) DEFAULT NULL COMMENT '个人的一句话;没填就用家庭统一的那句' AFTER `note`");
// 老数据里 birthday 若是 DATE,升成 DATETIME 后时间部分自动是 00:00:00,无需回填。 // 老数据里 birthday 若是 DATE,升成 DATETIME 后时间部分自动是 00:00:00,无需回填。
// 把老数据补出农历生日:只在还没算过时算一次(幂等)。 // 把老数据补出农历生日 / 身份证生日:只在还没算过时算一次(幂等)。
self::backfillLunarBirthday($db); // 这两步**只动已有数据、不改结构**,所以容错跑:某一行老数据有问题
self::backfillIdCardBirthday($db); // 不该把下面的改结构步骤全挡住(原因见 tryStep 的注释)。
self::tryStep('回填农历生日', static fn() => self::backfillLunarBirthday($db));
self::tryStep('回填身份证生日', static fn() => self::backfillIdCardBirthday($db));
// ---- 称呼从「人的属性」改成「视角属性」---- // ---- 称呼从「人的属性」改成「视角属性」----
// 老语义:persons.nickname 是全家共用的小名。 // 老语义:persons.nickname 是全家共用的小名。
@@ -211,30 +265,65 @@ SQL,
// 用户会觉得"以前填的小名一夜之间全没了"。搬的时候给每个账号都写一份, // 用户会觉得"以前填的小名一夜之间全没了"。搬的时候给每个账号都写一份,
// 迁移前后大家看到的叫法完全一致(`Nickname::migrateLegacy` 里说明了原因)。 // 迁移前后大家看到的叫法完全一致(`Nickname::migrateLegacy` 里说明了原因)。
// 放在结构升级里、而不是"某次请求里顺手做",是为了升级完就已经搬好。 // 放在结构升级里、而不是"某次请求里顺手做",是为了升级完就已经搬好。
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { self::tryStep('迁移旧称呼到 nicknames 表', static function () use ($db) {
\App\Nickname::migrateLegacy((int)$fam['id']); foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
} \App\Nickname::migrateLegacy((int)$fam['id']);
}
});
// ---- 关系分组(版本 4)---- // ---- 关系分组:从「全家共用一套」改成「公共一套 + 每人可加自己的」(版本 5)----
// persons 新增 group_id。这里**不加外键**:老库只能 ADD COLUMN,加不上约束, // person_groups 加 owner_id(0 = 公共)。新装的建表语句已是新结构,老库靠这里补。
// 新装的加了就会两边结构不一致;统一由 PersonGroup 保证不留悬空 id。 self::addColumn($db, 'person_groups', 'owner_id',
self::addColumn($db, 'persons', 'group_id', "ADD COLUMN `owner_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=全家的公共分组(管理员在系统设置里维护);否则=这个人的私自分组,只有他自己看得到' AFTER `family_id`");
"ADD COLUMN `group_id` INT UNSIGNED DEFAULT NULL COMMENT '关系分组(人属于哪一组:家人/同事/朋友…),全家人共用一套分类' AFTER `is_placeholder`"); // 唯一键要从 (family_id,name) 换成 (family_id,owner_id,name):
// 每个家庭都要有一套预置分组(新装的由 install.php 建,老的在这里补) // 否则「我的家人」和公共的「家人」会被判重名,没法各存一份。
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { self::dropIndexIfExists($db, 'person_groups', 'uk_group_name');
\App\PersonGroup::seedDefaults((int)$fam['id']); if (!self::hasIndex($db, 'person_groups', 'uk_group_owner_name')) {
$db->exec('ALTER TABLE `person_groups`
ADD UNIQUE KEY `uk_group_owner_name` (`family_id`, `owner_id`, `name`)');
}
// 公共分组(owner_id = 0):新装的由 install.php 建,老的在这里补
self::tryStep('预置公共分组', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\PersonGroup::seedDefaults((int)$fam['id']);
}
});
// 名册改成「每人一份」:先把老库「当时人人可见」的现状原样搬进每个人的名册,
// 再把 persons 上那两个「全家人共用」的列删掉。
//
// ⚠️ 这一步**绝不能**放进 tryStep:它是下面删列的**唯一前提**,
// 半途失败还继续删列 = 直接丢数据。宁可让异常把整站炸出来(下次请求会重试)。
\App\PersonRoster::seedFromPersons($db);
self::dropColumnIfExists($db, 'persons', 'group_id');
self::dropColumnIfExists($db, 'persons', 'sort_order');
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
if (self::$stepErrors) {
try {
\App\Setting::set((int)$db->query('SELECT MIN(id) FROM families')->fetchColumn(),
'schema_upgrade_warning', implode(';', self::$stepErrors));
} catch (\Throwable $e) {
// 连这条都记不上就算了,至少 error_log 里有
}
} }
// 老库的人还没有分组 → 按名册类型补:家庭成员=家人、外部联系人=认识。
// 只补空的,用户自己改过的一律不动。
\App\PersonGroup::backfillMissing($db);
} }
/** 老库已有阳历生日但没有农历生日 → 补算一次。用「为空」作条件,天然幂等。 */ /**
* ⚠️ `birthday_lunar = ''` 这种写法**只在本机的 MariaDB 上能跑**:
* MySQL 严格模式(线上是 8.0 + STRICT_TRANS_TABLES)会把右边的 `''`
* 按 DATETIME 解析,直接抛 `SQLSTATE[HY000] 1525 Incorrect DATETIME value: ''`。
* 而 DATETIME 列加了之后默认是 NULL、不可能是空串,所以 `IS NULL` 就够了。
* (2026-09-30 线上事故:这条比较让整个结构升级从这里抛出去,
* 被 index.php 静默吞掉 → schema_version 永远停在 2、
* persons.group_id 加不上 → 名册分组、保存成员、头像上传全废。)
* 用「为空」作条件,天然幂等。
*/
private static function backfillLunarBirthday(\PDO $db): void private static function backfillLunarBirthday(\PDO $db): void
{ {
$st = $db->query( $st = $db->query(
"SELECT id, birthday FROM persons "SELECT id, birthday FROM persons
WHERE birthday IS NOT NULL AND (birthday_lunar IS NULL OR birthday_lunar = '')" WHERE birthday IS NOT NULL AND birthday_lunar IS NULL"
); );
$rows = $st->fetchAll(); $rows = $st->fetchAll();
if (!$rows) { if (!$rows) {
@@ -312,4 +401,38 @@ SQL,
); );
} }
} }
private static function hasIndex(\PDO $db, string $table, string $index): bool
{
$st = $db->prepare(
'SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?'
);
$st->execute([$table, $index]);
return (int)$st->fetchColumn() > 0;
}
private static function dropIndexIfExists(\PDO $db, string $table, string $index): void
{
if (self::hasIndex($db, $table, $index)) {
$db->exec("ALTER TABLE `{$table}` DROP INDEX `{$index}`");
}
}
private static function hasColumn(\PDO $db, string $table, string $column): bool
{
$st = $db->prepare(
'SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'
);
$st->execute([$table, $column]);
return (int)$st->fetchColumn() > 0;
}
private static function dropColumnIfExists(\PDO $db, string $table, string $column): void
{
if (self::hasColumn($db, $table, $column)) {
$db->exec("ALTER TABLE `{$table}` DROP COLUMN `{$column}`");
}
}
} }
+71 -3
View File
@@ -166,7 +166,7 @@ a:hover { text-decoration: underline; }
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
} }
.person { .person {
display: block; color: var(--ink); display: block; color: var(--ink); position: relative;
background: var(--surface); border: 1px solid var(--line); background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); overflow: hidden; border-radius: var(--radius); overflow: hidden;
box-shadow: var(--shadow); box-shadow: var(--shadow);
@@ -596,6 +596,65 @@ textarea.input { min-height: 76px; resize: vertical; }
.person-move .select { flex: 1 1 auto; min-width: 0; padding: 6px 8px; font-size: 13px; } .person-move .select { flex: 1 1 auto; min-width: 0; padding: 6px 8px; font-size: 13px; }
.person-move .btn { flex: 0 0 auto; } .person-move .btn { flex: 0 0 auto; }
/* ---------------- 名册:拖动排序 / 拖到别栏改分组 ----------------
拖动是"锦上添花":没有它,卡片上的「⋯ → 调整分组」表单照样能改分组,
手机上 HTML5 拖放本来也不生效,那套表单就是窄屏的正路。 */
.roster.is-droppable .person { cursor: grab; }
.roster.is-droppable .person.is-dragged { opacity: .45; cursor: grabbing; }
.drag-handle {
position: absolute; left: 8px; top: 8px; z-index: 2;
width: 22px; height: 22px; line-height: 22px; text-align: center;
border-radius: 6px; font-size: 14px;
color: var(--muted); background: rgba(255, 255, 255, .82);
border: 1px solid var(--line-2);
pointer-events: none; /* 手柄只是个记号,抓取要落在整张卡上 */
}
.drag-handle-inline { color: var(--muted); }
/* 空栏平时收起来(七个预置分组可能只用到三个,摊开会显得名册破破烂烂)——
但**拖动时必须露出来**,否则"想拖到一个现在没人的分组里"就没地方放。 */
.group-block.is-empty { display: none; }
.roster.is-dragging .group-block.is-empty {
display: block; opacity: .9;
border: 1px dashed var(--line);
border-radius: var(--radius); padding: 10px; margin-top: 18px;
background: rgba(250, 247, 241, .6);
}
.group-drop-hint { display: none; margin-left: auto; font-weight: 400; font-size: 12px; color: var(--brand); }
.roster.is-dragging .group-block .group-drop-hint { display: inline; }
/* 拖动时把"有多少人"让出位置给落点提示,两者都挤在右上角会很乱 */
.roster.is-dragging .group-block .group-n { display: none; }
.roster.is-dragging .people-grid { min-height: 24px; }
/* 拖完/没拖成的轻提示(2 秒自己消失,不打断操作) */
.roster-note {
position: fixed; left: 50%; bottom: 28px; transform: translate(-50%, 12px);
z-index: 200; padding: 8px 16px; border-radius: 999px;
background: rgba(44, 35, 29, .88); color: #fff; font-size: 13px;
opacity: 0; pointer-events: none; transition: opacity .18s ease, transform .18s ease;
}
.roster-note.is-show { opacity: 1; transform: translate(-50%, 0); }
.roster-note.is-err { background: rgba(178, 45, 45, .92); }
/* ---------------- 分享名片(成员详情页) ---------------- */
.share-wrap { display: inline-block; }
.share-wrap > summary { list-style: none; cursor: pointer; display: inline-block; }
.share-wrap > summary::-webkit-details-marker { display: none; }
.share-wrap .inline-form { margin-top: 10px; }
/* ---------------- 「从家里已有的人里挑」 ---------------- */
.pick-list { list-style: none; margin: 0 0 4px; padding: 0; display: grid; gap: 8px; }
.pick-row { margin: 0; }
.pick-item {
display: flex; align-items: center; gap: 10px; cursor: pointer;
padding: 8px 12px; border-radius: var(--radius-s);
background: #FAF7F1; border: 1px solid var(--line-2);
}
.pick-item:hover { border-color: var(--brand); }
.pick-item input[type="checkbox"] { width: 16px; height: 16px; flex: 0 0 auto; accent-color: var(--brand); }
.pick-ph { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; background: #F1E7D6; }
.pick-name { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
/* ---------------- 个人设置:分组顺序 ---------------- */ /* ---------------- 个人设置:分组顺序 ---------------- */
.order-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } .order-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.order-row { .order-row {
@@ -651,12 +710,21 @@ textarea.input { min-height: 76px; resize: vertical; }
} }
.crop-head .crop-shape { font-size: 12px; font-weight: 400; color: var(--muted); } .crop-head .crop-shape { font-size: 12px; font-weight: 400; color: var(--muted); }
.crop-stage { .crop-stage {
position: relative; margin: 0 auto; overflow: hidden; position: relative; margin: 0 auto; overflow: auto;
background: #F1E7D6; border-radius: var(--radius-s); background: #F1E7D6; border-radius: var(--radius-s);
touch-action: none; /* 手机上拖框时别把页面一起滚走 */ touch-action: none; /* 手机上拖框时别把页面一起滚走 */
user-select: none; user-select: none;
} }
.crop-img { display: block; width: 100%; height: 100%; } /* 图片的宽高由 JS 按倍率写死(放大后比舞台大,靠舞台自身滚动看别处)——
所以这里**不能**写 width/height:100%,否则缩放就永远没效果。 */
.crop-img { display: block; max-width: none; }
/* 缩放条 —— 用户明确要求"裁剪的时候要能缩放" */
.crop-zoom-row {
display: flex; align-items: center; gap: 10px;
margin-top: 12px; font-size: 13px; color: var(--muted);
}
.crop-zoom { flex: 1; min-width: 120px; accent-color: var(--brand); }
.crop-zoom-val { font-variant-numeric: tabular-nums; min-width: 42px; text-align: right; }
/* 框外的部分压暗 —— 这是"你只留下框里这块"最直观的表达 */ /* 框外的部分压暗 —— 这是"你只留下框里这块"最直观的表达 */
.crop-box { .crop-box {
position: absolute; cursor: move; position: absolute; cursor: move;
+246 -12
View File
@@ -30,6 +30,13 @@
// 比例由**这个位置显示成什么形状**决定(头像 1:1、题图 16:9), // 比例由**这个位置显示成什么形状**决定(头像 1:1、题图 16:9),
// 由服务端通过 data-crop-ratio 告诉这里 —— 前端不再自己定一个数字。 // 由服务端通过 data-crop-ratio 告诉这里 —— 前端不再自己定一个数字。
// //
// 缩放(用户明确要求「裁剪图片的时候,需要可以进行缩放」):
// 舞台是个**固定大小的视口**(baseW×baseH),图片按 zoom 放大到 baseW*zoom,
// 放不下就由舞台自己滚动。裁剪框的坐标一律相对**显示出来的图片**算,
// 最后除以 dispW/dispH 换成 0..1 —— 所以放大多少倍都不影响落库的那个矩形。
// 框的最小边长固定 32 显示像素,于是放大之后能选中原图上更小的区域,
// 这正是"缩放"要解决的问题(合影里挑出某一个人的脸)。
//
// ⚠️ 这里只是「让用户挑区域」,真正裁切在服务端做(见 app/Upload.php): // ⚠️ 这里只是「让用户挑区域」,真正裁切在服务端做(见 app/Upload.php):
// 前端算出来的框只是**建议**,绕过前端也不会存进一张比例不对的图。 // 前端算出来的框只是**建议**,绕过前端也不会存进一张比例不对的图。
// 所以确认时写回的是**归一化矩形**(相对原图的 0..1),不是像素。 // 所以确认时写回的是**归一化矩形**(相对原图的 0..1),不是像素。
@@ -67,9 +74,15 @@
'<div class="crop-panel" role="dialog" aria-modal="true" aria-label="裁剪图片">' + '<div class="crop-panel" role="dialog" aria-modal="true" aria-label="裁剪图片">' +
'<div class="crop-head"><b>裁剪图片</b><span class="crop-shape"></span></div>' + '<div class="crop-head"><b>裁剪图片</b><span class="crop-shape"></span></div>' +
'<div class="crop-stage">' + '<div class="crop-stage">' +
'<img class="crop-img" alt="">' + '<img class="crop-img" alt="" draggable="false">' +
'<div class="crop-box"><i class="crop-handle" aria-hidden="true"></i></div>' + '<div class="crop-box"><i class="crop-handle" aria-hidden="true"></i></div>' +
'</div>' + '</div>' +
'<div class="crop-zoom-row">' +
'<label for="cropZoom">缩放</label>' +
'<input type="range" class="crop-zoom" id="cropZoom" min="1" max="4" step="0.05" value="1">' +
'<span class="crop-zoom-val">1.0×</span>' +
'<button type="button" class="btn btn-ghost btn-sm" data-act="zoom-reset">还原</button>' +
'</div>' +
'<p class="crop-tip">拖动方框挪位置,拖右下角改大小。框里就是最终会保存的样子。</p>' + '<p class="crop-tip">拖动方框挪位置,拖右下角改大小。框里就是最终会保存的样子。</p>' +
'<div class="crop-actions">' + '<div class="crop-actions">' +
'<button type="button" class="btn btn-ghost" data-act="cancel">取消</button>' + '<button type="button" class="btn btn-ghost" data-act="cancel">取消</button>' +
@@ -86,10 +99,24 @@
var act = e.target.getAttribute && e.target.getAttribute('data-act'); var act = e.target.getAttribute && e.target.getAttribute('data-act');
if (act === 'cancel') { close(false); } if (act === 'cancel') { close(false); }
if (act === 'ok') { close(true); } if (act === 'ok') { close(true); }
if (act === 'zoom-reset') { setZoom(1); }
}); });
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (overlay && overlay.parentNode && e.key === 'Escape') { close(false); } if (overlay && overlay.parentNode && e.key === 'Escape') { close(false); }
}); });
var zoomEl = overlay.querySelector('.crop-zoom');
zoomEl.addEventListener('input', function () {
var z = parseFloat(zoomEl.value);
if (isFinite(z) && z > 0) { setZoom(z); }
});
// Ctrl/⌘ + 滚轮 缩放;**普通滚轮不拦**,留给舞台自己滚动(放大之后要能平移)
stage.addEventListener('wheel', function (e) {
if (!st || !(e.ctrlKey || e.metaKey)) { return; }
e.preventDefault();
setZoom(st.zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
}, { passive: false });
bindDrag(overlay.querySelector('.crop-handle'), 'resize'); bindDrag(overlay.querySelector('.crop-handle'), 'resize');
bindDrag(boxEl, 'move'); bindDrag(boxEl, 'move');
} }
@@ -125,6 +152,7 @@
} }
var MIN = 32; // 裁剪框最小边长(显示像素),太小了没法拖 var MIN = 32; // 裁剪框最小边长(显示像素),太小了没法拖
var MAX_ZOOM = 4;
/** 把裁剪框放到指定位置,并夹在图片范围内 */ /** 把裁剪框放到指定位置,并夹在图片范围内 */
function place(x, y, w, h) { function place(x, y, w, h) {
@@ -140,6 +168,49 @@
boxEl.style.height = h + 'px'; boxEl.style.height = h + 'px';
} }
/** 当前框的归一化矩形(相对**显示出来的图片**,与倍率无关) */
function normOf() {
if (!st || !st.dispW || !st.dispH) { return { x: 0, y: 0, w: 1, h: 1 }; }
return {
x: boxEl.offsetLeft / st.dispW,
y: boxEl.offsetTop / st.dispH,
w: boxEl.offsetWidth / st.dispW,
h: boxEl.offsetHeight / st.dispH,
};
}
/**
* 改缩放。
*
* 关键:**框住的归一化区域保持不变**,只是把像素坐标整体重算 ——
* 于是"放大"只让你看得更清楚、能框得更细,绝不会把已经选好的区域改掉。
* 放大后把框滚回视野中间,不然用户会以为框不见了(框在滚动区外面)。
*/
function setZoom(z) {
if (!st) { return; }
z = Math.max(1, Math.min(MAX_ZOOM, z));
var before = normOf();
st.zoom = z;
st.dispW = Math.max(1, Math.round(st.baseW * z));
st.dispH = Math.max(1, Math.round(st.baseH * z));
imgEl.style.width = st.dispW + 'px';
imgEl.style.height = st.dispH + 'px';
// 舞台是**固定大小的视口**(不跟着图片变大),放不下就滚动
stage.style.width = st.baseW + 'px';
stage.style.height = st.baseH + 'px';
var zoomEl = overlay.querySelector('.crop-zoom');
if (zoomEl) { zoomEl.value = String(z); }
var valEl = overlay.querySelector('.crop-zoom-val');
if (valEl) { valEl.textContent = z.toFixed(1) + '×'; }
place(before.x * st.dispW, before.y * st.dispH,
before.w * st.dispW, before.h * st.dispH);
stage.scrollLeft = Math.max(0, boxEl.offsetLeft - (st.baseW - boxEl.offsetWidth) / 2);
stage.scrollTop = Math.max(0, boxEl.offsetTop - (st.baseH - boxEl.offsetHeight) / 2);
}
function open(input, file) { function open(input, file) {
if (!overlay) { build(); } if (!overlay) { build(); }
var ratio = parseRatioAttr(input.getAttribute('data-crop-ratio')); var ratio = parseRatioAttr(input.getAttribute('data-crop-ratio'));
@@ -151,28 +222,41 @@
var availW = Math.min(window.innerWidth * 0.86, 620); var availW = Math.min(window.innerWidth * 0.86, 620);
var availH = Math.min(window.innerHeight * 0.52, 420); var availH = Math.min(window.innerHeight * 0.52, 420);
var scale = Math.min(availW / probe.naturalWidth, availH / probe.naturalHeight, 1); var scale = Math.min(availW / probe.naturalWidth, availH / probe.naturalHeight, 1);
var dispW = Math.max(1, Math.round(probe.naturalWidth * scale)); var baseW = Math.max(1, Math.round(probe.naturalWidth * scale));
var dispH = Math.max(1, Math.round(probe.naturalHeight * scale)); var baseH = Math.max(1, Math.round(probe.naturalHeight * scale));
st = { input: input, ratio: ratio, dispW: dispW, dispH: dispH, st = { input: input, ratio: ratio,
baseW: baseW, baseH: baseH, zoom: 1,
dispW: baseW, dispH: baseH,
naturalW: probe.naturalWidth, naturalH: probe.naturalHeight, naturalW: probe.naturalWidth, naturalH: probe.naturalHeight,
previewSrc: null, outW: parseInt(input.getAttribute('data-crop-w'), 10) || dispW, previewSrc: null, outW: parseInt(input.getAttribute('data-crop-w'), 10) || baseW,
outH: parseInt(input.getAttribute('data-crop-h'), 10) || dispH }; outH: parseInt(input.getAttribute('data-crop-h'), 10) || baseH };
imgEl.src = url; imgEl.src = url;
stage.style.width = dispW + 'px'; imgEl.style.width = baseW + 'px';
stage.style.height = dispH + 'px'; imgEl.style.height = baseH + 'px';
stage.style.width = baseW + 'px';
stage.style.height = baseH + 'px';
stage.scrollLeft = 0;
stage.scrollTop = 0;
// ⚠️ 倍率每次打开都从 1× 开始:上一张图缩到 3× 不该带到这一张上,
// 否则新图的框会莫名其妙是上一张的"放大版"。
var zoomEl = overlay.querySelector('.crop-zoom');
if (zoomEl) { zoomEl.value = '1'; }
var valEl = overlay.querySelector('.crop-zoom-val');
if (valEl) { valEl.textContent = '1.0×'; }
var shape = overlay.querySelector('.crop-shape'); var shape = overlay.querySelector('.crop-shape');
shape.textContent = input.getAttribute('data-crop-label') || ''; shape.textContent = input.getAttribute('data-crop-label') || '';
overlay.querySelector('.crop-tip').textContent = overlay.querySelector('.crop-tip').textContent =
input.getAttribute('data-crop-hint') || input.getAttribute('data-crop-hint') ||
'拖动方框挪位置,拖右下角改大小。框里就是最终会保存的样子。'; '拖动方框挪位置,拖右下角改大小;放不下就用「缩放」放大再看细节。框里就是最终会保存的样子。';
// 默认框 = 图里最大的、符合目标比例的那块(居中)—— 什么都不动直接确定, // 默认框 = 图里最大的、符合目标比例的那块(居中)—— 什么都不动直接确定,
// 效果就等于老的「居中裁切」,所以默认值是安全的。 // 效果就等于老的「居中裁切」,所以默认值是安全的。
var w = dispW, h = w / ratio; var w = baseW, h = w / ratio;
if (h > dispH) { h = dispH; w = h * ratio; } if (h > baseH) { h = baseH; w = h * ratio; }
place((dispW - w) / 2, (dispH - h) / 2, w, h); place((baseW - w) / 2, (baseH - h) / 2, w, h);
// 记住预览图原来的 src:取消时恢复,否则预览会停在"没裁过的图"上 // 记住预览图原来的 src:取消时恢复,否则预览会停在"没裁过的图"上
var prev = preview(input); var prev = preview(input);
@@ -332,4 +416,154 @@
idc.addEventListener('input', calc); idc.addEventListener('input', calc);
calc(); calc();
})(); })();
// ===== 名册:拖动卡片改顺序 / 拖到别的一栏改分组 =====
//
// 落库的是**整版布局**(每栏的有序 id 列表),一次提交写完 ——
// 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。
//
// 没有这段 JS 也完全能用:卡片上的「⋯ → 调整分组」表单还在,
// 而且手机上 HTML5 拖放本来就不生效,那套表单就是窄屏的正路。
(function () {
var roster = document.querySelector('.roster.is-droppable');
if (!roster || !window.FormData || !window.fetch) { return; }
var url = roster.getAttribute('data-reorder');
var csrf = roster.getAttribute('data-csrf') || '';
var dragging = null;
var noteEl = null;
function each(list, fn) { Array.prototype.forEach.call(list, fn); }
/** 从事件目标往上找那张可拖的卡片(点在卡片里的链接/表单上时也算这张卡) */
function cardFrom(e) {
var t = e.target;
while (t && t !== roster) {
if (t.classList && t.classList.contains('person')
&& t.getAttribute('draggable') === 'true') {
return t;
}
t = t.parentNode;
}
return null;
}
function blocks() {
return roster.querySelectorAll('.group-block');
}
/** 每栏人数与"空栏"标记跟着 DOM 走 —— 不然拖完之后标题上的「N 人」还是旧的 */
function refreshCounts() {
each(blocks(), function (b) {
var n = b.querySelectorAll('.people-grid .person').length;
var nEl = b.querySelector('.group-n .n');
if (nEl) { nEl.textContent = String(n); }
b.classList.toggle('is-empty', n === 0);
});
}
/** 右下角那个小提示(成功/失败都只活两秒,不打断操作) */
function note(text, ok) {
if (!noteEl) {
noteEl = document.createElement('div');
noteEl.className = 'roster-note';
document.body.appendChild(noteEl);
}
noteEl.textContent = text;
noteEl.className = 'roster-note is-show' + (ok ? '' : ' is-err');
window.clearTimeout(note._t);
note._t = window.setTimeout(function () {
noteEl.className = 'roster-note';
}, 2200);
}
function save() {
var cols = [];
each(blocks(), function (b) {
var ids = [];
each(b.querySelectorAll('.people-grid .person'), function (c) {
var pid = parseInt(c.getAttribute('data-person'), 10);
if (pid > 0) { ids.push(pid); }
});
cols.push({ g: parseInt(b.getAttribute('data-group'), 10) || 0, ids: ids });
});
var body = new URLSearchParams();
body.append('_csrf', csrf);
body.append('layout', JSON.stringify(cols));
window.fetch(url, {
method: 'POST',
credentials: 'same-origin',
// 让服务端知道这是 AJAX:它就回 JSON 而不是 302(不然 flash 会攒到下次刷新才冒出来)
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: body,
}).then(function (r) {
return r.ok ? r.json() : null;
}).then(function (j) {
if (j && j.ok) {
note('已保存', true);
} else {
note('没保存上,刷新页面再试一次', false);
}
}).catch(function () {
note('没保存上,刷新页面再试一次', false);
});
}
roster.addEventListener('dragstart', function (e) {
var card = cardFrom(e);
if (!card) { return; }
// 「⋯」菜单里的下拉与按钮不参与拖动 —— 在那儿按下多半是想选项,不是想拖卡
var t = e.target;
while (t && t !== roster) {
if (t.getAttribute && t.getAttribute('data-no-drag') !== null) {
e.preventDefault();
return;
}
t = t.parentNode;
}
dragging = card;
roster.classList.add('is-dragging');
card.classList.add('is-dragged');
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', card.getAttribute('data-person')); } catch (err) { /* 老浏览器忽略 */ }
}
});
roster.addEventListener('dragover', function (e) {
if (!dragging) { return; }
var block = e.target && e.target.closest ? e.target.closest('.group-block') : null;
if (!block) { return; }
e.preventDefault();
if (e.dataTransfer) { e.dataTransfer.dropEffect = 'move'; }
var grid = block.querySelector('.people-grid');
// 落点 = 指针在谁的上半部分,就插到谁前面(和列表拖拽的直觉一致)
var after = null;
each(grid.querySelectorAll('.person'), function (c) {
if (after || c === dragging) { return; }
var r = c.getBoundingClientRect();
if (e.clientY < r.top + r.height / 2) { after = c; }
});
if (after) {
grid.insertBefore(dragging, after);
} else {
grid.appendChild(dragging);
}
});
roster.addEventListener('drop', function (e) {
// 不 preventDefault 的话浏览器会按"跳到某个地址"处理,整页就跳走了
if (dragging) { e.preventDefault(); }
});
roster.addEventListener('dragend', function () {
if (!dragging) { return; }
dragging.classList.remove('is-dragged');
roster.classList.remove('is-dragging');
dragging = null;
refreshCounts();
save();
});
})();
})(); })();
+310 -115
View File
@@ -20,6 +20,7 @@ use App\Kinship;
use App\Nickname; use App\Nickname;
use App\Person; use App\Person;
use App\PersonGroup; use App\PersonGroup;
use App\PersonRoster;
use App\Relations; use App\Relations;
use App\Schema; use App\Schema;
use App\Setting; use App\Setting;
@@ -56,7 +57,13 @@ if (Database::hasTables()) {
Schema::ensure(); Schema::ensure();
Setting::set($familyIdForSchema, 'schema_version', FB_SCHEMA_VERSION); Setting::set($familyIdForSchema, 'schema_version', FB_SCHEMA_VERSION);
} catch (\Throwable $ex) { } catch (\Throwable $ex) {
// 升级失败不能把整站挡住;下次请求会再试 // 升级失败不能把整站挡住;下次请求会再试。
// ⚠️ 但**必须留下痕迹**:这里曾经是空的 catch,结果线上结构升级
// 从 2026-09-30 起一次都没跑完(报错被吞、版本号永远停在 2),
// 而首页/名册都 200,看着完全正常 —— 直到「个人设置 500」才暴露。
// 一行 error_log 就能省掉几小时排查,别再删掉它。
error_log('[familybook] Schema::ensure() 失败(已跳过,下次请求重试):'
. $ex->getMessage() . ' @ ' . $ex->getFile() . ':' . $ex->getLine());
if (!empty($GLOBALS['config']['debug'])) { if (!empty($GLOBALS['config']['debug'])) {
throw $ex; throw $ex;
} }
@@ -104,12 +111,14 @@ function read_birthday_input(array $existing = []): array
/** /**
* 渲染「个人设置」页(settings/me)。 * 渲染「个人设置」页(settings/me)。
* *
* 抽成函数是因为它有三处调用者:正常打开、以及资料表单校验失败时的两处 * 从 v5 起这一页只剩两件事:**我怎么称呼家人**、**我的分组**。
* ("我的资料"和"改密码"各一次)—— 那三处的数据装配必须完全一致, * 「我的资料」表单不在这里 —— 它和「成员资料」是同一张表单
* 否则校验失败重画的那次会缺东西(比如分组顺序整块消失),且不会有任何报错。 * (`templates/people/form.php`,两条入口都指过去),页面顶部给一个按钮过去。
* 这是用户明确要求的:「编辑成员」和「修改自己的资料」本来就是一件事,
* 两套表单必然填出两种口径。
* *
* @param array $person 当前登录者自己的名册记录 * @param array $person 当前登录者自己的名册记录
* @param array $errors 字段名 => 错误文案 * @param array $errors 字段名 => 错误文案(保留给"校验失败就地重画"用)
*/ */
function render_me_settings(array $person, array $errors = []): void function render_me_settings(array $person, array $errors = []): void
{ {
@@ -119,14 +128,14 @@ function render_me_settings(array $person, array $errors = []): void
'title' => '个人设置', 'title' => '个人设置',
'person' => $person, 'person' => $person,
'errors' => $errors, 'errors' => $errors,
'familyMotto' => (string)(Family::find($familyId)['motto'] ?? ''),
// 我怎么称呼别人(视角属性,只有我自己能定) // 我怎么称呼别人(视角属性,只有我自己能定)
'nicknames' => Nickname::mineWithNames($familyId, $me), 'nicknames' => Nickname::mineWithNames($familyId, $me),
'options' => Person::options($familyId, $me, $me), 'options' => Person::options($familyId, $me, $me),
'birthdays' => Birthday::describe($person), 'birthdays' => Birthday::describe($person),
// 名册分组:顺序是「我的」,分类是「全家的」 // 我的分组 = 公共的 + 我自己加的,顺序是我的(user_settings.group_order)
'groups' => PersonGroup::orderFor($familyId, $me), 'groups' => PersonGroup::orderFor($familyId, $me),
'groupCounts' => PersonGroup::counts($familyId, 'member'), 'groupCounts' => PersonGroup::counts($familyId, $me),
'rosterCount' => PersonRoster::countFor($familyId, $me),
], 'settings'); ], 'settings');
} }
@@ -266,7 +275,9 @@ switch ($r) {
$q = trim((string)($_GET['q'] ?? '')); $q = trim((string)($_GET['q'] ?? ''));
$familyId = (int)Auth::familyId(); $familyId = (int)Auth::familyId();
$me = (int)Auth::personId(); $me = (int)Auth::personId();
$rows = Person::all($familyId, $q, 'member', $me); // ⚠️ 用 roster() 而不是 all():名册是「每人一份」——
// 只列我加的人 + 别人分享给我的(v5 起,见 App\PersonRoster)。
$rows = Person::roster($familyId, $me, 'member', $q);
$people = Person::map($familyId); $people = Person::map($familyId);
$graph = Kinship::graph($familyId); $graph = Kinship::graph($familyId);
// 名册里也标出「我该怎么称呼他」 // 名册里也标出「我该怎么称呼他」
@@ -290,18 +301,24 @@ switch ($r) {
'groups' => PersonGroup::orderFor($familyId, $me), 'groups' => PersonGroup::orderFor($familyId, $me),
'q' => $q, 'q' => $q,
'meId' => $me, 'meId' => $me,
// 家里已经有人、但还不在我名册里 → 给一个「直接挑」的入口,
// 免得为了同一个人又录一遍(录两遍会变成两条记录,账号与亲属关系各存一份)
'pickable' => $q === '' ? count(PersonRoster::pickable($familyId, $me)) : 0,
], 'people'); ], 'people');
break; break;
/** /**
* 名册卡片上的「调整分组」。 * 名册卡片上的「调整分组」。
* *
* 单开一个路由而不是让人去「编辑成员」里改,是因为那个表单很长 * 从 v5 起改的是**我自己那份名册**里的归类(person_roster.group_id),
* 不再动 persons —— 所以「我把张伟归到同事」不会让妈妈那边也变成同事。
* 单开一个路由而不是让人去「编辑成员」里改,是因为那张表很长
* (三个生日、身份证号、住址…),为了挪一个分组去翻半页是折磨。 * (三个生日、身份证号、住址…),为了挪一个分组去翻半页是折磨。
* 只改 group_id 一列,所以走 Person::setGroup(),不会碰到别的字段。 * 而且分组**本来就只能在名册里改**(它是"我和他的关系")。
*/ */
case 'people/group': case 'people/group':
$familyId = (int)Auth::familyId(); $familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
if (!Auth::canWrite()) { if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改名册里的分组。'); flash('err', '你的身份是访客,不能改名册里的分组。');
redirect('people'); redirect('people');
@@ -314,19 +331,152 @@ switch ($r) {
flash('err', '找不到这个人。'); flash('err', '找不到这个人。');
redirect('people'); redirect('people');
} }
// 分组必须也是这个家庭的 —— 否则可以把人挂到别人家的分组上, if (!PersonRoster::assignGroup($familyId, $me, $pid, $gid > 0 ? $gid : null)) {
// 名册按 group_id 分栏时就永远看不见他了 flash('err', '这个人不在你的名册里,或者那个分组你用不了。');
if ($gid > 0 && !isset(PersonGroup::byId($familyId)[$gid])) {
flash('err', '找不到那个分组。');
redirect('people'); redirect('people');
} }
Person::setGroup($pid, $gid > 0 ? $gid : null);
flash('ok', $gid > 0 flash('ok', $gid > 0
? '已把「' . $p['name'] . '」归到「' . PersonGroup::name($familyId, $gid) . '」。' ? '已把「' . $p['name'] . '」归到「' . PersonGroup::name($familyId, $gid) . '」。'
: '已把「' . $p['name'] . '」改成未分类。'); : '已把「' . $p['name'] . '」改成未分类。');
redirect('people'); redirect('people');
break; break;
/**
* 名册拖动排序 / 拖动换栏 —— 整版提交。
*
* 前端把每一栏(含「未分类」)的有序 id 列表发过来,服务端一次写完。
* 为什么不"移动一条算一次":拖拽的结果是一组**相对顺序**,
* 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。
* 没有 JS 也能用:卡片上的「调整分组」表单还在(只是没有拖拽那么顺手)。
*/
case 'people/reorder':
Auth::requireLogin();
// 拖动是 AJAX 发的(见 app.js)→ 出错也回 JSON 而不是 302:
// 不然那句 flash 会攒到"下一次刷新"才冒出来,看着像迟到的鬼影。
$isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest');
if (!Auth::canWrite()) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 403);
echo json_encode(['ok' => false, 'error' => 'readonly']);
exit;
}
flash('err', '你的身份是访客,不能调整名册顺序。');
redirect('people');
}
csrf_check();
$raw = (string)($_POST['layout'] ?? '');
$layout = json_decode($raw, true);
if (!is_array($layout)) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'invalid layout']);
exit;
}
flash('err', '没收到有效的顺序,请刷新页面再试一次。');
redirect('people');
}
$n = PersonRoster::applyLayout((int)Auth::familyId(), (int)Auth::personId(), $layout);
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => true, 'n' => $n], JSON_UNESCAPED_UNICODE);
exit;
}
flash('ok', $n > 0 ? '名册顺序已更新。' : '没有需要改动的。');
redirect('people');
break;
/**
* 「从家里已有的人里挑」—— 添加成员的第二个入口。
*
* 存在的理由:同一个人不该被录两遍。录两遍会变成两条 persons,
* 于是账号、亲属关系、生日各存一份,后面永远对不上。
* 所以只要人已经在这个家庭里(别人先录过了),就走这里把他**复用**进我的名册。
*/
case 'people/pick':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能往名册里加人。');
redirect('people');
}
$familyPick = PersonRoster::pickable($familyId, $me);
if ($method === 'POST') {
csrf_check();
$ids = array_map('intval', (array)($_POST['ids'] ?? []));
$gid = (int)($_POST['group_id'] ?? 0);
// 只认「确实可挑」的那些 id —— 表单可以改,不能凭它把别人家的拉进来
$ok = array_column($familyPick, 'id');
$ok = array_map('intval', $ok);
$n = 0;
foreach ($ids as $pid) {
if (!in_array($pid, $ok, true)) {
continue;
}
if (PersonRoster::ensure($familyId, $me, $pid, $gid > 0 ? $gid : null, 'self')) {
$n++;
}
}
flash($n > 0 ? 'ok' : 'err', $n > 0
? '已把 ' . $n . ' 个人加进你的名册。'
: '一个人都没加上 —— 先勾选要加的人。');
redirect('people');
}
View::render('people/pick', [
'title' => '从家里已有的人里挑',
'people' => $familyPick,
'groups' => PersonGroup::orderFor($familyId, $me),
'meId' => $me,
], 'people');
break;
/**
* 把一张名片分享到别人的名册里(需求原话:「我可以把一个人的名片,分享给一个用户,
* 则这个名片,自动出现在这个用户的名册中」)。
* 这只是"让他看得见",不改变任何资料本身;对方可以自己决定归到哪一组。
*/
case 'people/share':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能分享名片。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_id'] ?? 0);
$to = (int)($_POST['to_id'] ?? 0);
$back = (int)($_POST['back'] ?? 0);
$p = Person::find($pid);
$t = Person::find($to);
if (!$p || !$t || (int)$p['family_id'] !== $familyId || (int)$t['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
if ($pid === $to) {
flash('err', '不用把名片分享给他自己。');
redirect('people/show', ['id' => $back > 0 ? $back : $pid]);
}
PersonRoster::share($familyId, $to, [$pid], 'shared');
flash('ok', '已把「' . $p['name'] . '」的名片放进「' . $t['name'] . '」的名册里。');
redirect('people/show', ['id' => $back > 0 ? $back : $pid]);
break;
/** 把一个人从**我的**名册里移掉(人还在名册与家谱里,只是我不再看到他) */
case 'people/forget':
Auth::requireLogin();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改名册。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_id'] ?? 0);
$p = Person::find($pid);
if ($p && (int)$p['family_id'] === (int)Auth::familyId()) {
PersonRoster::remove((int)Auth::familyId(), (int)Auth::personId(), $pid);
flash('ok', '已把「' . $p['name'] . '」从你的名册里移走(他还在家里)。');
}
redirect('people');
break;
// ==================== 我自己的资料 / 个人设置 ==================== // ==================== 我自己的资料 / 个人设置 ====================
// 顶栏头像点开 →「个人设置」。访客也能改自己的资料 —— // 顶栏头像点开 →「个人设置」。访客也能改自己的资料 ——
// 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。 // 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。
@@ -347,56 +497,21 @@ switch ($r) {
render_me_settings($p); render_me_settings($p);
break; break;
case 'settings/me_save': // 表单现在提交到这里 /**
case 'people/me_save': // 兼容旧链接/旧标签页里还没刷新的表单 * 旧的「保存我的资料」入口。
Auth::requireLogin(); *
csrf_check(); * v5 起资料表单只剩一张(`templates/people/form.php`,提交到 people/update),
$me = (int)Auth::personId(); * 所以这两个地址不再处理表单 —— 只把还开着旧标签页的人送回个人设置,
$p = Person::find($me); * 免得一次提交撞上 404 让人以为"保存坏了"。
if (!$p) { */
flash('err', '找不到你自己的名册记录。'); case 'settings/me_save':
redirect('home'); case 'people/me_save':
}
$d = [
'name' => trim((string)($_POST['name'] ?? '')),
'gender' => (string)($_POST['gender'] ?? ''),
'email' => trim((string)($_POST['email'] ?? '')),
'address' => trim((string)($_POST['address'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')),
'slogan' => trim((string)($_POST['slogan'] ?? '')),
'avatar' => (string)$p['avatar'],
] + read_birthday_input($p);
$errors = Person::validate($d);
// 归一化裁剪框由前端浮层写进 avatar_crop;留空 = 没裁,退回居中
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? [],
(string)($_POST['avatar_crop'] ?? ''));
if (!$up['ok'] && ($up['error'] ?? '') !== '') {
$errors['avatar'] = $up['error'];
}
if ($errors) {
set_old($_POST);
render_me_settings($p, $errors);
exit;
}
if ($up['ok']) {
Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
$d['avatar'] = $up['file'];
}
$warn = Birthday::normalize($d)['warning'];
Person::updateSelf($me, $d);
if ($warn !== '') {
flash('err', $warn);
}
// 姓名/头像改了要同步会话,否则顶栏还是旧的
$_SESSION['name'] = $d['name'];
Auth::refreshAvatar((string)$d['avatar']);
clear_old();
flash('ok', '已保存。');
redirect('settings/me'); redirect('settings/me');
break; break;
// ==================== 分组顺序(只影响我自己) ==================== // ==================== 分组的顺序 ====================
// 两套:「我自己的顺序」(只影响我看到的)与「公共分组的默认顺序」(全家兜底)。
// 靠表单里的 scope 字段区分 —— 同一个动作,落到两个地方。
case 'settings/groups/move': case 'settings/groups/move':
Auth::requireLogin(); Auth::requireLogin();
csrf_check(); csrf_check();
@@ -404,74 +519,106 @@ switch ($r) {
$me = (int)Auth::personId(); $me = (int)Auth::personId();
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
$dir = ($_POST['dir'] ?? '') === 'up' ? 'up' : 'down'; $dir = ($_POST['dir'] ?? '') === 'up' ? 'up' : 'down';
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
if ($scope === 'public') {
Auth::requireAdmin();
}
$list = PersonGroup::orderFor($familyId, $me); $list = $scope === 'public'
? PersonGroup::forOwner($familyId, PersonGroup::PUBLIC_OWNER)
: PersonGroup::orderFor($familyId, $me);
$ids = array_map(fn($g) => (int)$g['id'], $list); $ids = array_map(fn($g) => (int)$g['id'], $list);
$at = array_search($id, $ids, true); $at = array_search($id, $ids, true);
if ($at === false) { if ($at === false) {
flash('err', '找不到这个分组。'); flash('err', '找不到这个分组。');
redirect('settings/me'); redirect($scope === 'public' ? 'settings' : 'settings/me');
} }
// 挪到边界就什么也不做(按钮在两端本来就是 disabled 的, // 挪到边界就什么也不做(按钮在两端本来就是 disabled 的,
// 但地址栏里手敲一次也能到这儿,别让它把顺序搞乱) // 但地址栏里手敲一次也能到这儿,别让它把顺序搞乱)
$to = $dir === 'up' ? $at - 1 : $at + 1; $to = $dir === 'up' ? $at - 1 : $at + 1;
if ($to >= 0 && $to < count($ids)) { if ($to >= 0 && $to < count($ids)) {
[$ids[$at], $ids[$to]] = [$ids[$to], $ids[$at]]; [$ids[$at], $ids[$to]] = [$ids[$to], $ids[$at]];
PersonGroup::saveOrder($familyId, $me, $ids); if ($scope === 'public') {
PersonGroup::savePositions($familyId, PersonGroup::PUBLIC_OWNER, $ids);
} else {
PersonGroup::saveOrder($familyId, $me, $ids);
}
} }
redirect('settings/me'); redirect($scope === 'public' ? 'settings' : 'settings/me');
break; break;
// ==================== 关系分类的增删改名(全家共用) ==================== // ==================== 分组的增删改名 ====================
// 分类写在共享的表里,所以是"能写"的人才能动;访客只读,给个明确的说法再回去, // 两套:公共的(管理员在系统设置里维护,全家共用)与我自己的(个人设置里,
// 只有自己看得见)。访客只读,给个明确的说法再回去,
// 而不是甩一张 403 页面(他明明是点了自己设置里的按钮)。 // 而不是甩一张 403 页面(他明明是点了自己设置里的按钮)。
case 'settings/groups/add': case 'settings/groups/add':
Auth::requireLogin(); Auth::requireLogin();
csrf_check(); csrf_check();
if (!Auth::canWrite()) { if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改关系分类。'); flash('err', '你的身份是访客,不能加分组。');
redirect('settings/me'); redirect('settings/me');
} }
$res = PersonGroup::add((int)Auth::familyId(), (string)($_POST['name'] ?? '')); $scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
$owner = PersonGroup::PUBLIC_OWNER;
if ($scope === 'public') {
Auth::requireAdmin();
} else {
$owner = (int)Auth::personId();
}
$res = PersonGroup::add((int)Auth::familyId(), (string)($_POST['name'] ?? ''), $owner);
flash($res['ok'] ? 'ok' : 'err', $res['ok'] flash($res['ok'] ? 'ok' : 'err', $res['ok']
? '已加上分类「' . trim((string)($_POST['name'] ?? '')) . '」。' ? '已加上分组「' . trim((string)($_POST['name'] ?? '')) . '」。'
: (string)($res['error'] ?? '加不上。')); : (string)($res['error'] ?? '加不上。'));
redirect('settings/me'); redirect($scope === 'public' ? 'settings' : 'settings/me');
break; break;
case 'settings/groups/rename': case 'settings/groups/rename':
Auth::requireLogin(); Auth::requireLogin();
csrf_check(); csrf_check();
if (!Auth::canWrite()) { if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改关系分类。'); flash('err', '你的身份是访客,不能改分组。');
redirect('settings/me'); redirect('settings/me');
} }
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
$owner = PersonGroup::PUBLIC_OWNER;
if ($scope === 'public') {
Auth::requireAdmin();
} else {
$owner = (int)Auth::personId();
}
$res = PersonGroup::rename((int)Auth::familyId(), (int)($_POST['id'] ?? 0), $res = PersonGroup::rename((int)Auth::familyId(), (int)($_POST['id'] ?? 0),
(string)($_POST['name'] ?? '')); (string)($_POST['name'] ?? ''), $owner);
flash($res['ok'] ? 'ok' : 'err', $res['ok'] flash($res['ok'] ? 'ok' : 'err', $res['ok']
? '分类名已改。' ? '分组名已改。'
: (string)($res['error'] ?? '改不了。')); : (string)($res['error'] ?? '改不了。'));
redirect('settings/me'); redirect($scope === 'public' ? 'settings' : 'settings/me');
break; break;
case 'settings/groups/delete': case 'settings/groups/delete':
Auth::requireLogin(); Auth::requireLogin();
csrf_check(); csrf_check();
if (!Auth::canWrite()) { if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改关系分类。'); flash('err', '你的身份是访客,不能删分组。');
redirect('settings/me'); redirect('settings/me');
} }
$res = PersonGroup::delete((int)Auth::familyId(), (int)($_POST['id'] ?? 0)); $scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
$owner = PersonGroup::PUBLIC_OWNER;
if ($scope === 'public') {
Auth::requireAdmin();
} else {
$owner = (int)Auth::personId();
}
$res = PersonGroup::delete((int)Auth::familyId(), (int)($_POST['id'] ?? 0), $owner);
if ($res['ok']) { if ($res['ok']) {
$n = (int)($res['moved'] ?? 0); $n = (int)($res['moved'] ?? 0);
flash('ok', $n > 0 flash('ok', $n > 0
? '分类已删掉,那 ' . $n . ' 个人现在显示在「未分类」里,去名册重新归类即可。' ? '分组已删掉,那 ' . $n . ' 个人现在显示在「未分类」里,去名册重新归类即可。'
: '分类已删掉(那一组本来也没人)。'); : '分组已删掉(那一组本来也没人)。');
} else { } else {
flash('err', (string)($res['error'] ?? '删不了。')); flash('err', (string)($res['error'] ?? '删不了。'));
} }
redirect('settings/me'); redirect($scope === 'public' ? 'settings' : 'settings/me');
break; break;
// ==================== 别人怎么称呼(只有我自己能定) ==================== // ==================== 别人怎么称呼(只有我自己能定) ====================
@@ -503,16 +650,18 @@ switch ($r) {
Auth::requireWrite(); Auth::requireWrite();
clear_old(); clear_old();
View::render('people/form', [ View::render('people/form', [
'title' => '添加成员', 'title' => '添加成员',
'person' => null, 'person' => null,
'errors' => [], 'errors' => [],
'groups' => PersonGroup::byId((int)Auth::familyId()), 'canEditAll' => true,
'isSelf' => false,
], 'people'); ], 'people');
break; break;
case 'people/create': case 'people/create':
Auth::requireWrite(); Auth::requireWrite();
csrf_check(); csrf_check();
$familyId = (int)Auth::familyId();
$d = [ $d = [
'name' => trim((string)($_POST['name'] ?? '')), 'name' => trim((string)($_POST['name'] ?? '')),
'gender' => (string)($_POST['gender'] ?? ''), 'gender' => (string)($_POST['gender'] ?? ''),
@@ -522,19 +671,16 @@ switch ($r) {
'address' => trim((string)($_POST['address'] ?? '')), 'address' => trim((string)($_POST['address'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')),
'slogan' => trim((string)($_POST['slogan'] ?? '')), 'slogan' => trim((string)($_POST['slogan'] ?? '')),
'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
'avatar' => '', 'avatar' => '',
'kind' => 'member', 'kind' => 'member',
// ⚠️ 这两项必须显式从表单取回:表单里有,但漏了这一步就会被静默丢掉, // ⚠️ 这两项必须显式从表单取回:表单里有,但漏了这一步就会被静默丢掉,
// 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。 // 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')), 'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')),
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0, 'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
// 关系分组:下拉里的「未分类」是空 value,于是这里落成 null
'group_id' => !empty($_POST['group_id']) ? (int)$_POST['group_id'] : null,
] + read_birthday_input(); ] + read_birthday_input();
$errors = Person::validate($d); $errors = Person::validate($d);
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? [], $up = Upload::avatar($familyId, $_FILES['avatar'] ?? [],
(string)($_POST['avatar_crop'] ?? '')); (string)($_POST['avatar_crop'] ?? ''));
if (!$up['ok'] && ($up['error'] ?? '') !== '') { if (!$up['ok'] && ($up['error'] ?? '') !== '') {
$errors['avatar'] = $up['error']; $errors['avatar'] = $up['error'];
@@ -547,7 +693,8 @@ switch ($r) {
'errors' => $errors, 'errors' => $errors,
'phoneValue' => $d['phone'], 'phoneValue' => $d['phone'],
'idCardValue'=> $d['id_card'], 'idCardValue'=> $d['id_card'],
'groups' => PersonGroup::byId((int)Auth::familyId()), 'canEditAll' => true,
'isSelf' => false,
], 'people'); ], 'people');
exit; exit;
} }
@@ -555,7 +702,7 @@ switch ($r) {
$d['avatar'] = $up['file']; $d['avatar'] = $up['file'];
} }
try { try {
$id = Person::create((int)Auth::familyId(), $d); $id = Person::create($familyId, $d);
} catch (\PDOException $ex) { } catch (\PDOException $ex) {
set_old($_POST); set_old($_POST);
View::render('people/form', [ View::render('people/form', [
@@ -564,59 +711,82 @@ switch ($r) {
'errors' => ['phone' => '这个手机号已经在名册里了'], 'errors' => ['phone' => '这个手机号已经在名册里了'],
'phoneValue' => $d['phone'], 'phoneValue' => $d['phone'],
'idCardValue'=> $d['id_card'], 'idCardValue'=> $d['id_card'],
'groups' => PersonGroup::byId((int)Auth::familyId()), 'canEditAll' => true,
'isSelf' => false,
], 'people'); ], 'people');
exit; exit;
} }
// 我加的人,自动进**我的**名册(名册是每人一份的,v5 起)。
// 分组先留空 —— 用户可以在名册里拖到对应那一栏,也可以在那张卡片的「调整分组」里选。
PersonRoster::ensure($familyId, (int)Auth::personId(), $id, null, 'self');
clear_old(); clear_old();
flash('ok', '已把「' . $d['name'] . '」加进名册。'); flash('ok', '已把「' . $d['name'] . '」加进名册。');
redirect('people/show', ['id' => $id]); redirect('people/show', ['id' => $id]);
break; break;
/**
* 成员资料表单 —— **只有这一张**(需求:「编辑成员」与「改自己的资料」本来就是一件事)。
*
* 两个入口都指到这里:名册卡片/详情页的「编辑资料」、以及个人设置顶部的
* 「编辑我的资料」。区别只有一个:编辑**自己**时不要求能写(访客也能改自己的名字),
* 而编辑别人必须是能写的人。
*/
case 'people/edit': case 'people/edit':
Auth::requireWrite(); $id = (int)($_GET['id'] ?? 0);
$p = Person::find((int)($_GET['id'] ?? 0)); $isSelf = $id > 0 && $id === (int)Auth::personId();
if (!$isSelf) {
Auth::requireWrite();
}
$p = Person::find($id);
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) { if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
flash('err', '找不到这个人。'); flash('err', '找不到这个人。');
redirect('people'); redirect('people');
} }
clear_old(); clear_old();
View::render('people/form', [ View::render('people/form', [
'title' => '编辑成员', 'title' => $isSelf ? '我的资料' : '编辑成员',
'person' => $p, 'person' => $p,
'errors' => [], 'errors' => [],
'canEditAll' => Auth::canWrite(),
'isSelf' => $isSelf,
'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']), 'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']),
'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']), 'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']),
'groups' => PersonGroup::byId((int)Auth::familyId()),
], 'people'); ], 'people');
break; break;
case 'people/update': case 'people/update':
Auth::requireWrite();
csrf_check(); csrf_check();
$familyId = (int)Auth::familyId();
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
$p = Person::find($id); $p = Person::find($id);
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) { if (!$p || (int)$p['family_id'] !== $familyId) {
flash('err', '找不到这个人。'); flash('err', '找不到这个人。');
redirect('people'); redirect('people');
} }
$isSelf = $id === (int)Auth::personId();
if (!$isSelf) {
Auth::requireWrite();
}
// 改别人(= 能写的人编名册):字段是完整的一套。
// 改自己(可能是访客):只允许动属于自己的那些 —— 与 Person::updateSelf 一一对应。
$canEditAll = Auth::canWrite();
$d = [ $d = [
'name' => trim((string)($_POST['name'] ?? '')), 'name' => trim((string)($_POST['name'] ?? '')),
'gender' => (string)($_POST['gender'] ?? ''), 'gender' => (string)($_POST['gender'] ?? ''),
'phone' => trim((string)($_POST['phone'] ?? '')), 'phone' => $canEditAll ? trim((string)($_POST['phone'] ?? '')) : Crypto::decrypt((string)$p['phone_encrypted']),
'id_card' => trim((string)($_POST['id_card'] ?? '')), 'id_card' => $canEditAll ? trim((string)($_POST['id_card'] ?? '')) : Crypto::decrypt((string)$p['id_card_encrypted']),
'email' => trim((string)($_POST['email'] ?? '')), 'email' => trim((string)($_POST['email'] ?? '')),
'address' => trim((string)($_POST['address'] ?? '')), 'address' => trim((string)($_POST['address'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')),
'slogan' => trim((string)($_POST['slogan'] ?? '')), 'slogan' => trim((string)($_POST['slogan'] ?? '')),
'sort_order'=> (int)($_POST['sort_order'] ?? 1000),
'avatar' => (string)$p['avatar'], 'avatar' => (string)$p['avatar'],
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')), 'birth_rank' => $canEditAll ? trim((string)($_POST['birth_rank'] ?? '')) : ($p['birth_rank'] ?? ''),
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0, 'is_placeholder' => $canEditAll
'group_id' => !empty($_POST['group_id']) ? (int)$_POST['group_id'] : null, ? (!empty($_POST['is_placeholder']) ? 1 : 0)
: (int)($p['is_placeholder'] ?? 0),
] + read_birthday_input($p); ] + read_birthday_input($p);
$errors = Person::validate($d); $errors = Person::validate($d);
$up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? [], $up = Upload::avatar($familyId, $_FILES['avatar'] ?? [],
(string)($_POST['avatar_crop'] ?? '')); (string)($_POST['avatar_crop'] ?? ''));
if (!$up['ok'] && ($up['error'] ?? '') !== '') { if (!$up['ok'] && ($up['error'] ?? '') !== '') {
$errors['avatar'] = $up['error']; $errors['avatar'] = $up['error'];
@@ -624,35 +794,46 @@ switch ($r) {
if ($errors) { if ($errors) {
set_old($_POST); set_old($_POST);
View::render('people/form', [ View::render('people/form', [
'title' => '编辑成员', 'title' => $isSelf ? '我的资料' : '编辑成员',
'person' => $p, 'person' => $p,
'errors' => $errors, 'errors' => $errors,
'canEditAll' => $canEditAll,
'isSelf' => $isSelf,
'phoneValue' => $d['phone'], 'phoneValue' => $d['phone'],
'idCardValue' => $d['id_card'], 'idCardValue' => $d['id_card'],
'groups' => PersonGroup::byId((int)Auth::familyId()),
], 'people'); ], 'people');
exit; exit;
} }
if ($up['ok']) { if ($up['ok']) {
Upload::remove((int)Auth::familyId(), (string)$p['avatar']); Upload::remove($familyId, (string)$p['avatar']);
$d['avatar'] = $up['file']; $d['avatar'] = $up['file'];
} }
try { try {
Person::update($id, $d); // 改自己且没有编名册的权限 → 只能动属于自己的字段(Person::updateSelf)。
// 这一条不是"防贼",是为了让访客也能改自己的名字,同时不会顺手把
// 手机号(= 登录凭据)或身份证号改掉。
if ($isSelf && !$canEditAll) {
Person::updateSelf($id, $d);
} else {
Person::update($id, $d);
}
} catch (\PDOException $ex) { } catch (\PDOException $ex) {
set_old($_POST); set_old($_POST);
View::render('people/form', [ View::render('people/form', [
'title' => '编辑成员', 'title' => $isSelf ? '我的资料' : '编辑成员',
'person' => $p, 'person' => $p,
'errors' => ['phone' => '这个手机号已经被名册里的另一个人用了'], 'errors' => ['phone' => '这个手机号已经被名册里的另一个人用了'],
'canEditAll' => $canEditAll,
'isSelf' => $isSelf,
'phoneValue' => $d['phone'], 'phoneValue' => $d['phone'],
'idCardValue' => $d['id_card'], 'idCardValue' => $d['id_card'],
'groups' => PersonGroup::byId((int)Auth::familyId()),
], 'people'); ], 'people');
exit; exit;
} }
if ((int)$p['id'] === (int)Auth::personId()) { if ((int)$p['id'] === (int)Auth::personId()) {
Auth::refreshAvatar((string)$d['avatar']); Auth::refreshAvatar((string)$d['avatar']);
// 姓名也可能改了 —— 不同步会话的话顶栏还是旧名字
$_SESSION['name'] = (string)$d['name'];
} }
clear_old(); clear_old();
flash('ok', '已保存。'); flash('ok', '已保存。');
@@ -692,6 +873,8 @@ switch ($r) {
'shown' => $id === $me ? (string)$p['name'] : (Nickname::display($me, $p) ?: (string)$p['name']), 'shown' => $id === $me ? (string)$p['name'] : (Nickname::display($me, $p) ?: (string)$p['name']),
'birthdays' => Birthday::describe($p), 'birthdays' => Birthday::describe($p),
'birthdayNext'=> Birthday::nextOccurrences($p, date('Y-m-d'), 400), 'birthdayNext'=> Birthday::nextOccurrences($p, date('Y-m-d'), 400),
// 名册(每人一份):他是不是在我的名册里、可以把他的名片分享给谁
'inMyRoster' => in_array($id, PersonRoster::visibleIds($familyId, $me), true),
], 'people'); ], 'people');
break; break;
@@ -756,8 +939,12 @@ switch ($r) {
$p = Person::find($personId); $p = Person::find($personId);
$acc = Account::findByPerson($personId); $acc = Account::findByPerson($personId);
if ($p && $acc) { if ($p && $acc) {
if ($role === 'member' && $acc['role'] === 'admin' && Account::adminCount() <= 1) { // ⚠️ 判的是 `$role !== 'admin'` 而不是 `$role === 'member'`:
flash('err', '至少要留一位管理员。'); // 早先只拦"降成家庭成员",于是把**最后一位管理员**降成访客能一路通过 ——
// 降完家里一个管理员都不剩,谁也进不了系统设置了。
// 允许改自己的权限(包括给自己降级),只在"会一个管理员都不剩"时拦。
if ($role !== 'admin' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
flash('err', '至少要留一位管理员 —— 把最后一位管理员降级,全家就没人能管理系统了。');
redirect('people/show', ['id' => $personId]); redirect('people/show', ['id' => $personId]);
} }
Account::setRole((int)$acc['id'], $role); Account::setRole((int)$acc['id'], $role);
@@ -873,9 +1060,12 @@ switch ($r) {
$pid = Person::create($familyId, [ $pid = Person::create($familyId, [
'name' => $name, 'gender' => '', 'avatar' => '', 'phone' => $phone, 'name' => $name, 'gender' => '', 'avatar' => '', 'phone' => $phone,
'id_card' => '', 'email' => '', 'address' => '', 'note' => $note, 'id_card' => '', 'email' => '', 'address' => '', 'note' => $note,
'slogan' => '', 'sort_order' => 2000, 'kind' => 'contact', 'slogan' => '', 'kind' => 'contact',
]); ]);
Account::create($pid, $pass, 'guest'); Account::create($pid, $pass, 'guest');
// 我邀的访客也进**我的**名册(名册是每人一份的;访客自己那份由
// Account::create 给他补上他自己)
PersonRoster::ensure($familyId, (int)Auth::personId(), $pid, null, 'self');
clear_old(); clear_old();
flash('ok', '访客「' . $name . '」已开通,把手机号和密码告诉他就能登录。'); flash('ok', '访客「' . $name . '」已开通,把手机号和密码告诉他就能登录。');
redirect('accounts/invite'); redirect('accounts/invite');
@@ -906,6 +1096,8 @@ switch ($r) {
// 诊断结果由 settings/test_oss 写进会话,这里取出即用即清(POST → 302 → GET) // 诊断结果由 settings/test_oss 写进会话,这里取出即用即清(POST → 302 → GET)
$diag = $_SESSION['_oss_diag'] ?? null; $diag = $_SESSION['_oss_diag'] ?? null;
unset($_SESSION['_oss_diag']); unset($_SESSION['_oss_diag']);
// 公共分组(owner_id = 0)—— 管理员在这里维护全家共用的那一套。
// 人数按"全家名册"统计(这里要看的是"这一组里全家有多少人",不是我个人那份)。
View::render('settings', [ View::render('settings', [
'title' => '设置', 'title' => '设置',
'family' => Family::find((int)Auth::familyId()), 'family' => Family::find((int)Auth::familyId()),
@@ -913,6 +1105,9 @@ switch ($r) {
'keyOk' => $rawKey !== '' && strpos($rawKey, '请填写') !== 0, 'keyOk' => $rawKey !== '' && strpos($rawKey, '请填写') !== 0,
'roleCounts' => $acc, 'roleCounts' => $acc,
'relCount' => Relations::count((int)Auth::familyId()), 'relCount' => Relations::count((int)Auth::familyId()),
'pubGroups' => PersonGroup::forOwner((int)Auth::familyId(), PersonGroup::PUBLIC_OWNER),
'pubCounts' => PersonGroup::counts((int)Auth::familyId(), PersonGroup::PUBLIC_OWNER),
'upgradeWarn'=> (string)Setting::get((int)Auth::familyId(), 'schema_upgrade_warning', ''),
'storageDriver' => \App\Storage\Storage::driver((int)Auth::familyId()), 'storageDriver' => \App\Storage\Storage::driver((int)Auth::familyId()),
'storageOss' => \App\Storage\Storage::config((int)Auth::familyId()), 'storageOss' => \App\Storage\Storage::config((int)Auth::familyId()),
'storageOk' => \App\Storage\Storage::configComplete((int)Auth::familyId()), 'storageOk' => \App\Storage\Storage::configComplete((int)Auth::familyId()),
+5 -5
View File
@@ -70,15 +70,15 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
'address' => '', 'address' => '',
'note' => '', 'note' => '',
'slogan' => '', 'slogan' => '',
'sort_order'=> 1,
'kind' => 'member', 'kind' => 'member',
]); ]);
Account::create($personId, $password, 'admin'); Account::create($personId, $password, 'admin');
// 创始人(=第一个管理员)默认归到「家人」。 // 名册是「每人一份」(v5 起)。创始人先把自己放进自己的名册 ——
// 用 backfillMissing 而不是自己算组 id:规则「member → 家人」只写在一处, // 之后他每加一个人,那个人就自动进他的名册(见 index.php 的 people/create)。
// 安装与老库升级才不会有第二种说法。 // ⚠️ 这里**不能**把全家成员都塞给他:新装时除他之外还没有别人,
\App\PersonGroup::backfillMissing(Database::get()); // 而"别人"要靠他自己加或复用,这正是"每人一份"的意思。
\App\PersonRoster::ensure((int)$familyId, (int)$personId, (int)$personId, null, 'self');
// 装完就删掉自己 —— 避免把安装入口长期暴露在公网 // 装完就删掉自己 —— 避免把安装入口长期暴露在公网
@unlink(__FILE__); @unlink(__FILE__);
-3
View File
@@ -52,9 +52,6 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '
<?php if ($mottoIsMine): ?> <?php if ($mottoIsMine): ?>
<span class="motto-from">我的一句话</span> <span class="motto-from">我的一句话</span>
<?php endif; ?> <?php endif; ?>
<?php if ($me): ?>
<a class="motto-edit" href="<?= e(url('settings/me')) ?>#me-profile"><?= $mottoIsMine ? '改' : '写一句我自己的' ?></a>
<?php endif; ?>
</p> </p>
</div> </div>
<div class="hero-stats"> <div class="hero-stats">
-15
View File
@@ -26,7 +26,6 @@ $genWord = function (int $level): string {
<div class="page-head"> <div class="page-head">
<h1>我的家谱</h1> <h1>我的家谱</h1>
<span class="sub">以 <?= e(\App\Auth::name()) ?> 为中心 · 上下各三代</span>
</div> </div>
<?php if (!$nodes || count($nodes) <= 1): ?> <?php if (!$nodes || count($nodes) <= 1): ?>
@@ -42,7 +41,6 @@ $genWord = function (int $level): string {
<div class="tree-gen"> <div class="tree-gen">
<div class="tree-gen-label"> <div class="tree-gen-label">
<span class="gname"><?= e($genWord((int)$level)) ?></span> <span class="gname"><?= e($genWord((int)$level)) ?></span>
<span class="gtag"><?= (int)$level === 0 ? '中心' : ((int)$level < 0 ? '上 ' . abs((int)$level) . ' 代' : '下 ' . (int)$level . ' 代') ?></span>
</div> </div>
<div class="tree-row"> <div class="tree-row">
<?php foreach ($ids as $id): $n = $nodes[$id]; ?> <?php foreach ($ids as $id): $n = $nodes[$id]; ?>
@@ -63,19 +61,6 @@ $genWord = function (int $level): string {
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<div class="card card-pad" style="margin-top:24px">
<h2 class="section-title">这张图是怎么来的 <span class="hint">只存两代,其余推导</span></h2>
<p class="small muted" style="margin:0 0 10px">
系统里只记录**相邻两代之间**的关系:谁是谁的父母、谁和谁是配偶。
至于「奶奶是谁」,是顺着「我的父亲 → 我父亲的母亲」两级边推出来的。
这样每条关系只需维护一次,同一个人在每个人眼里还会自动变成不同的称呼。
</p>
<p class="small muted" style="margin:0">
兄弟姐妹也不单独存档 —— 只要你们有同一对父母,系统就会把你们认成一家人,
并按排行显示成哥哥/姐姐/弟弟/妹妹、长子/次女。
</p>
</div>
<div class="btn-row"> <div class="btn-row">
<a class="btn btn-ghost" href="<?= e(url('people/show', ['id' => $meId])) ?>">补充我的亲属关系</a> <a class="btn btn-ghost" href="<?= e(url('people/show', ['id' => $meId])) ?>">补充我的亲属关系</a>
<a class="btn btn-ghost" href="<?= e(url('people')) ?>">看整个名册</a> <a class="btn btn-ghost" href="<?= e(url('people')) ?>">看整个名册</a>
+2 -3
View File
@@ -1,12 +1,11 @@
<?php <?php
// ⚠️ 「系统设置」**不在**顶栏里(用户明确要求):它已经收进右上角头像下拉菜单里了,
// 顶栏只留「首页 / 家谱 / 名册」三个日常入口,别再把管理入口顶出来。
$navItems = [ $navItems = [
'home' => ['首页', 'home'], 'home' => ['首页', 'home'],
'kin' => ['家谱', 'kin'], 'kin' => ['家谱', 'kin'],
'people' => ['名册', 'people'], 'people' => ['名册', 'people'],
]; ];
if (\App\Auth::isAdmin()) {
$navItems['settings'] = ['设置', 'settings'];
}
// ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的, // ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的,
// 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。 // 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。
// home.php 用 $me 画「补充亲属关系」链接,被覆盖成 true 之后 // home.php 用 $me 画「补充亲属关系」链接,被覆盖成 true 之后
+32 -26
View File
@@ -13,6 +13,11 @@
*/ */
$isEdit = !empty($person['id']); $isEdit = !empty($person['id']);
$avatarUrl = avatar_url($person['avatar'] ?? null); $avatarUrl = avatar_url($person['avatar'] ?? null);
// 这张表单有**两个入口**(名册/详情页的「编辑资料」、个人设置里的「编辑我的资料」),
// 但只有一套字段。改自己、且不是能写的人(访客)时,只给属于他自己的那些 ——
// 见 index.php 的 people/update(那条分支调 Person::updateSelf)。
$canEditAll = $canEditAll ?? \App\Auth::canWrite();
$isSelf = $isSelf ?? false;
$bdSource = old('birthday_source', 'solar'); $bdSource = old('birthday_source', 'solar');
// 库里这列叫 birthday、表单叫 birthday_solar,一律走 solarOf() 取,别直接下标 // 库里这列叫 birthday、表单叫 birthday_solar,一律走 solarOf() 取,别直接下标
@@ -27,7 +32,7 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
?> ?>
<div class="page-head"> <div class="page-head">
<h1><?= $isEdit ? '编辑成员' : '添加成员' ?></h1> <h1><?= $isEdit ? ($isSelf ? '我的资料' : '编辑成员') : '添加成员' ?></h1>
<span class="sub"><?= $isEdit ? e($person['name']) : '填姓名就行,其他都可以以后再补' ?></span> <span class="sub"><?= $isEdit ? e($person['name']) : '填姓名就行,其他都可以以后再补' ?></span>
</div> </div>
@@ -57,6 +62,7 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
</select> </select>
</div> </div>
<?php if ($canEditAll): ?>
<div class="field"> <div class="field">
<label for="birth_rank">家中排行</label> <label for="birth_rank">家中排行</label>
<input class="input" id="birth_rank" name="birth_rank" type="number" min="1" max="99" <input class="input" id="birth_rank" name="birth_rank" type="number" min="1" max="99"
@@ -64,31 +70,15 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
<span class="tip">用来认兄弟姐妹的长幼:长子、次女、三弟…</span> <span class="tip">用来认兄弟姐妹的长幼:长子、次女、三弟…</span>
<?php if (!empty($errors['birth_rank'])): ?><span class="err"><?= e($errors['birth_rank']) ?></span><?php endif; ?> <?php if (!empty($errors['birth_rank'])): ?><span class="err"><?= e($errors['birth_rank']) ?></span><?php endif; ?>
</div> </div>
<?php endif; ?>
<div class="field"> <?php /* ⚠️ 这里**没有**「排序号」,也**没有**「关系分组」:
<label for="sort_order">排序号</label> ① 排序号已经彻底取消(用户原话:「排序号,不要让用户填写」),
<input class="input" id="sort_order" name="sort_order" type="number" 名册里的先后靠**拖动卡片**调,存在我自己的 person_roster.sort_order 上;
value="<?= e(old('sort_order', $person['sort_order'] ?? 1000)) ?>"> ② 分组是「我和他的关系」,写在我自己的名册条目上 ——
<span class="tip">小的排前面。</span> 只能在名册里拖到那一栏,或在那张卡片的「调整分组」里改。
</div> 写进这张(全家人共用的)资料表单里是错的:我把他归成"同事"
不该把妈妈那边的归类也改掉。 */ ?>
<?php /* 一个人只属于一个关系分组;分组是全家共用的一套(名册按它分栏显示),
但「哪一组排前面」是各人自己的习惯,在「个人设置」里调。 */ ?>
<div class="field">
<label for="group_id">关系分组</label>
<select class="select" id="group_id" name="group_id">
<option value="">未分类</option>
<?php foreach (($groups ?? []) as $g): $gid = (int)$g['id']; ?>
<option value="<?= $gid ?>"
<?= (int)old('group_id', $person['group_id'] ?? 0) === $gid ? 'selected' : '' ?>>
<?= e((string)$g['name']) ?>
</option>
<?php endforeach; ?>
</select>
<span class="tip">
名册会按这个分组分栏显示。分组不够用就自己在「<a href="<?= e(url('settings/me')) ?>">个人设置</a>」里加。
</span>
</div>
<!-- ==================== 三个生日 ==================== --> <!-- ==================== 三个生日 ==================== -->
<div class="field full"> <div class="field full">
@@ -153,6 +143,7 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
<?php if (!empty($errors['slogan'])): ?><span class="err"><?= e($errors['slogan']) ?></span><?php endif; ?> <?php if (!empty($errors['slogan'])): ?><span class="err"><?= e($errors['slogan']) ?></span><?php endif; ?>
</div> </div>
<?php if ($canEditAll): ?>
<div class="field"> <div class="field">
<label for="phone">手机号</label> <label for="phone">手机号</label>
<input class="input" id="phone" name="phone" inputmode="numeric" <input class="input" id="phone" name="phone" inputmode="numeric"
@@ -160,6 +151,7 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
<span class="tip">开通登录后用它登录;加密存储,列表里只显示部分。</span> <span class="tip">开通登录后用它登录;加密存储,列表里只显示部分。</span>
<?php if (!empty($errors['phone'])): ?><span class="err"><?= e($errors['phone']) ?></span><?php endif; ?> <?php if (!empty($errors['phone'])): ?><span class="err"><?= e($errors['phone']) ?></span><?php endif; ?>
</div> </div>
<?php endif; ?>
<div class="field"> <div class="field">
<label for="email">邮箱</label> <label for="email">邮箱</label>
@@ -168,6 +160,7 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
<?php if (!empty($errors['email'])): ?><span class="err"><?= e($errors['email']) ?></span><?php endif; ?> <?php if (!empty($errors['email'])): ?><span class="err"><?= e($errors['email']) ?></span><?php endif; ?>
</div> </div>
<?php if ($canEditAll): ?>
<div class="field full"> <div class="field full">
<label for="id_card">身份证号</label> <label for="id_card">身份证号</label>
<input class="input mono" id="id_card" name="id_card" <input class="input mono" id="id_card" name="id_card"
@@ -179,6 +172,7 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
</span> </span>
<?php if (!empty($errors['id_card'])): ?><span class="err"><?= e($errors['id_card']) ?></span><?php endif; ?> <?php if (!empty($errors['id_card'])): ?><span class="err"><?= e($errors['id_card']) ?></span><?php endif; ?>
</div> </div>
<?php endif; ?>
<div class="field full"> <div class="field full">
<label for="address">住址</label> <label for="address">住址</label>
@@ -215,6 +209,7 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
style="width:88px;height:88px;border-radius:12px;object-fit:cover;border:1px solid var(--line)"> style="width:88px;height:88px;border-radius:12px;object-fit:cover;border:1px solid var(--line)">
</div> </div>
<?php if ($canEditAll): ?>
<div class="field full"> <div class="field full">
<label class="check-item"> <label class="check-item">
<input type="checkbox" name="is_placeholder" value="1" <input type="checkbox" name="is_placeholder" value="1"
@@ -228,12 +223,23 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
</span> </span>
</label> </label>
</div> </div>
<?php endif; ?>
</div> </div>
<div class="btn-row"> <div class="btn-row">
<button class="btn" type="submit"><?= $isEdit ? '保存' : '加入名册' ?></button> <button class="btn" type="submit"><?= $isEdit ? '保存' : '加入名册' ?></button>
<a class="btn btn-ghost" href="<?= e($isEdit ? url('people/show', ['id' => (int)$person['id']]) : url('people')) ?>">取消</a> <?php
// 取消:改自己的话回「个人设置」更顺手(那是他来的地方),
// 改别人 / 新增则回他的详情页 / 名册。
$cancelUrl = $isEdit
? ($isSelf ? url('settings/me') : url('people/show', ['id' => (int)$person['id']]))
: url('people');
?>
<a class="btn btn-ghost" href="<?= e($cancelUrl) ?>">取消</a>
<?php if ($isSelf): ?>
<a class="btn btn-ghost" href="<?= e(url('people/show', ['id' => (int)$person['id']])) ?>">看我的详情页</a>
<?php endif; ?>
</div> </div>
</div> </div>
</form> </form>
+107 -52
View File
@@ -1,20 +1,24 @@
<?php <?php
/** /**
* 家庭名册 —— 按「关系分组」分栏显示。 * 家庭名册 —— 按「关系分组」分栏显示,卡片可以拖动。
* *
* 两个刻意的设计: * 三处刻意的设计:
* ① **分栏顺序由看的人自己定**($groups 已经是"按我的习惯排好"的顺序, * ① **名册是每人一份的**(v5 起,见 App\PersonRoster):这里只显示"我加的人 +
* 见 App\PersonGroup::orderFor)—— 所以我看到的「家人」在第一栏, * 别人分享给我的",归到哪一组、排第几也都只影响我自己。所以"改分组"不再是
* 妈妈可以把「亲戚」挪到第一栏,两个人相安无事。 * 改全家的信息,而是改我自己那份 —— 于是顺手就能做成拖动。
* ② 分类是全家共用的一套,所以**改一个人的分组是全家的信息**, * ② **拖动改分组、拖动改顺序**(需求原话:「拖动一个人的卡片进行排序…通过拖动
* 这里只有能写的人(管理员 / 家庭成员)才看得到那一行「调整分组」。 * 改变分组」):拖动本身是 JS,但**每个分组栏也是空的时候照样存在**,
* 拖动时统一露出来当落点,否则"想拖到一个现在没人的分组里"就没地方放。
* ③ **没有 JS 也完全能用**:卡片上的 `<details>调整分组</details>` 还在,
* 而且手机上的 HTML5 拖放本来就不生效,那套表单就是窄屏的正路。
* *
* ⚠️ 卡片结构是 div > a + details,**不能写成 a > form**: * ⚠️ 卡片结构是 div > a + details,**不能写成 a > form**:
* 表单套在链接里是非法 HTML,浏览器会把 DOM 拆开,表单里那个「移动」按钮 * 表单套在链接里是非法 HTML,浏览器会把 DOM 拆开,表单里那个「移动」按钮
* 可能连提交机会都没有(或者把整张卡片点成跳转)。 * 可能连提交机会都没有(或者把整张卡片点成跳转)。
*/ */
$canMove = \App\Auth::canWrite(); $canMove = \App\Auth::canWrite();
$total = 0; $pickable = $pickable ?? 0;
$total = 0;
foreach ($grouped as $list) { foreach ($grouped as $list) {
$total += count($list); $total += count($list);
} }
@@ -22,11 +26,13 @@ foreach ($grouped as $list) {
/** /**
* 画一栏人。用闭包是因为"分好组的各栏"和"未分类"两处要画同一套卡片, * 画一栏人。用闭包是因为"分好组的各栏"和"未分类"两处要画同一套卡片,
* 复制一遍迟早会改漏一处。 * 复制一遍迟早会改漏一处。
*
* ⚠️ `$groups` 必须写进 `use (...)`:卡片里的「调整分组」下拉要列出我能用的分组。
* 漏掉它的后果**不会报错**(PHP 8 只 warning 一句"未定义变量",
* 而 foreach 拿到 null 就是零次循环)—— 于是每张卡片的那个下拉里
* 只剩「未分类」一项,看着像"分组功能没做",其实是变量没传进来。
*/ */
$renderGrid = function (array $list) use ($groups, $meId, $canMove): void { $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
if (!$list) {
return;
}
?> ?>
<div class="people-grid"> <div class="people-grid">
<?php foreach ($list as $p): ?> <?php foreach ($list as $p): ?>
@@ -40,9 +46,14 @@ $renderGrid = function (array $list) use ($groups, $meId, $canMove): void {
$pNick = (string)($p['my_nickname'] ?? ''); $pNick = (string)($p['my_nickname'] ?? '');
$pid = (int)$p['id']; $pid = (int)$p['id'];
?> ?>
<div class="person"> <div class="person" data-person="<?= $pid ?>" <?= $canMove ? 'draggable="true"' : '' ?>>
<a class="person-main" href="<?= e(url('people/show', ['id' => $pid])) ?>"> <?php if ($canMove): ?>
<div class="ph"><img src="<?= e(avatar_url($p['avatar'])) ?>" alt=""></div> <span class="drag-handle" title="拖到我想要的位置 / 那一栏" aria-hidden="true">⠿</span>
<?php endif; ?>
<?php /* draggable="false" 很重要:链接与图片浏览器默认就是可拖的,
不关掉的话拖起来会变成"拖一个网址",父卡片反而拿不到这次拖动。 */ ?>
<a class="person-main" href="<?= e(url('people/show', ['id' => $pid])) ?>" draggable="false">
<div class="ph"><img src="<?= e(avatar_url($p['avatar'])) ?>" alt="" draggable="false"></div>
<div class="pb"> <div class="pb">
<div class="pname"> <div class="pname">
<?= e($pshow) ?> <?= e($pshow) ?>
@@ -71,13 +82,13 @@ $renderGrid = function (array $list) use ($groups, $meId, $canMove): void {
</div> </div>
</a> </a>
<?php if ($canMove): ?> <?php if ($canMove): ?>
<details class="person-more"> <details class="person-more" data-no-drag>
<summary>调整分组</summary> <summary aria-label="调整分组">⋯</summary>
<form method="post" action="<?= e(url('people/group')) ?>" class="person-move"> <form method="post" action="<?= e(url('people/group')) ?>" class="person-move">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>"> <input type="hidden" name="person_id" value="<?= $pid ?>">
<label class="sr-only" for="pg<?= $pid ?>">分组</label> <label class="sr-only" for="pg<?= $pid ?>">分组</label>
<select class="select" id="pg<?= $pid ?>" name="group_id"> <select class="select" id="pg<?= $pid ?>" name="group_id" data-no-drag>
<option value="">未分类</option> <option value="">未分类</option>
<?php foreach ($groups as $g): $gid = (int)$g['id']; ?> <?php foreach ($groups as $g): $gid = (int)$g['id']; ?>
<option value="<?= $gid ?>" <option value="<?= $gid ?>"
@@ -86,7 +97,12 @@ $renderGrid = function (array $list) use ($groups, $meId, $canMove): void {
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<button class="btn btn-ghost btn-sm" type="submit">移动</button> <button class="btn btn-ghost btn-sm" type="submit" data-no-drag>移动</button>
</form>
<form method="post" action="<?= e(url('people/forget')) ?>" class="person-move">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>">
<button class="btn btn-ghost btn-sm" type="submit" data-no-drag>从我的名册移走</button>
</form> </form>
</details> </details>
<?php endif; ?> <?php endif; ?>
@@ -95,18 +111,46 @@ $renderGrid = function (array $list) use ($groups, $meId, $canMove): void {
</div> </div>
<?php <?php
}; };
/** 一栏(含空栏)。空栏平时收起来,拖动时由 JS 加 is-dragging 露出来当落点。 */
$renderBlock = function (int $gid, string $name, array $list) use ($renderGrid): void {
?>
<section class="group-block<?= $list ? '' : ' is-empty' ?>" data-group="<?= $gid ?>">
<h2 class="group-head">
<span class="group-title"><?= e($name) ?></span>
<span class="group-n"><span class="n"><?= count($list) ?></span> 人</span>
<span class="group-drop-hint">放到这里</span>
</h2>
<?php $renderGrid($list); ?>
</section>
<?php
};
?> ?>
<div class="page-head"> <div class="page-head">
<h1>家庭名册</h1> <h1>家庭名册</h1>
<span class="sub">共 <?= $total ?> 人<?= $groups && $q === '' ? ',分 ' . count($groups) . ' 个关系分组' : '' ?></span> <span class="sub">
共 <?= $total ?> 人<?= $groups && $q === '' ? ',分 ' . count($groups) . ' 个关系分组' : '' ?>
</span>
<div class="actions"> <div class="actions">
<?php if ($canMove && $pickable > 0): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/pick')) ?>">
从家里已有的人里挑(<?= $pickable ?>)
</a>
<?php endif; ?>
<?php if (\App\Auth::isAdmin()): ?> <?php if (\App\Auth::isAdmin()): ?>
<a class="btn btn-sm" href="<?= e(url('people/new')) ?>">添加成员</a> <a class="btn btn-sm" href="<?= e(url('people/new')) ?>">添加成员</a>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
<?php if ($canMove && $q === '' && $total > 0): ?>
<p class="small muted roster-tip" style="margin:0 0 12px">
拖动卡片(左边那个 <span class="drag-handle-inline">⠿</span>)就能改顺序,
拖到别的一栏里就是改分组。改的是<b>你自己那份名册</b>,不影响家里其他人。
</p>
<?php endif; ?>
<form class="search" method="get" action="index.php"> <form class="search" method="get" action="index.php">
<input type="hidden" name="r" value="people"> <input type="hidden" name="r" value="people">
<input class="input" name="q" value="<?= e($q) ?>" placeholder="按姓名、称呼或邮箱搜索"> <input class="input" name="q" value="<?= e($q) ?>" placeholder="按姓名、称呼或邮箱搜索">
@@ -118,8 +162,29 @@ $renderGrid = function (array $list) use ($groups, $meId, $canMove): void {
<?php if (!$people): ?> <?php if (!$people): ?>
<div class="empty"> <div class="empty">
<b><?= $q !== '' ? '没有匹配的人' : '名册还是空的' ?></b> <?php if ($q !== ''): ?>
<?= $q !== '' ? '换个词试试。' : '把家里人加进来,之后观影、旅行的同行人就可以直接从名册里选。' ?> <b>没有匹配的人</b>
换个词试试。
<?php elseif ($pickable > 0): ?>
<b>你的名册还是空的</b>
家里已经有 <?= (int)$pickable ?> 个人了 —— 直接挑过来就行,不用重新录一遍。
<div class="btn-row" style="justify-content:center;margin-top:14px">
<a class="btn" href="<?= e(url('people/pick')) ?>">从家里已有的人里挑</a>
<?php if (\App\Auth::isAdmin()): ?>
<a class="btn btn-ghost" href="<?= e(url('people/new')) ?>">添加一个新人</a>
<?php endif; ?>
</div>
<?php else: ?>
<b>名册还是空的</b>
<?php if (\App\Auth::isAdmin()): ?>
把家里人加进来,之后观影、旅行的同行人就可以直接从名册里选。
<div class="btn-row" style="justify-content:center;margin-top:14px">
<a class="btn" href="<?= e(url('people/new')) ?>">添加成员</a>
</div>
<?php else: ?>
等家里人把你加进来,或者让管理员给你开个账号。
<?php endif; ?>
<?php endif; ?>
</div> </div>
<?php elseif ($q !== ''): ?> <?php elseif ($q !== ''): ?>
<?php /* 搜索时按"命中的人"平铺 —— 分栏会把一次搜索拆成一堆只有一两个人的碎块,反而更难看 */ ?> <?php /* 搜索时按"命中的人"平铺 —— 分栏会把一次搜索拆成一堆只有一两个人的碎块,反而更难看 */ ?>
@@ -128,35 +193,25 @@ $renderGrid = function (array $list) use ($groups, $meId, $canMove): void {
</p> </p>
<?php $renderGrid($people); ?> <?php $renderGrid($people); ?>
<?php else: ?> <?php else: ?>
<?php foreach ($groups as $g): ?> <div class="roster<?= $canMove ? ' is-droppable' : '' ?>"
<?php data-reorder="<?= e(url('people/reorder')) ?>"
$gid = (int)$g['id']; data-csrf="<?= e(csrf_token()) ?>">
$list = $grouped[$gid] ?? []; <?php foreach ($groups as $g): ?>
// 空分组不画 —— 七个预置分类里家里可能只用到三个, <?php $gid = (int)$g['id']; ?>
// 把空栏也摊出来会让名册看上去破破烂烂的 <?php $renderBlock($gid, (string)$g['name'], $grouped[$gid] ?? []); ?>
if (!$list) { <?php endforeach; ?>
continue;
}
?>
<section class="group-block">
<h2 class="group-head">
<span class="group-title"><?= e((string)$g['name']) ?></span>
<span class="group-n"><?= count($list) ?> 人</span>
</h2>
<?php $renderGrid($list); ?>
</section>
<?php endforeach; ?>
<?php if (!empty($grouped[0])): ?> <?php
<section class="group-block"> // 「未分类」栏永远画出来(哪怕是空的)—— 它是把卡片从某个分组里拖出来的落点。
<h2 class="group-head"> // 提示语按需求删掉了(用户原话:「这些人还没归到任何分组里…这些提示,都去掉」)。
<span class="group-title">未分类</span> $renderBlock(0, '未分类', $grouped[0] ?? []);
<span class="group-n"><?= count($grouped[0]) ?> 人</span> ?>
</h2>
<p class="small muted" style="margin:-4px 0 12px"> <?php if (!$groups): ?>
这些人还没归到任何分组里。管理员可以在卡片下面直接选一个分组(或去「编辑成员」里改)。 <p class="small muted" style="margin:0">
还没有任何分组。管理员可以在「系统设置 → 公共分组」里建一套全家共用的,
你也可以在「个人设置 → 我的分组」里加只给自己看的。
</p> </p>
<?php $renderGrid($grouped[0]); ?> <?php endif; ?>
</section> </div>
<?php endif; ?>
<?php endif; ?> <?php endif; ?>
+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* 「从家里已有的人里挑」—— 添加成员的第二个入口。
*
* 为什么要有这一页:同一个人不该被录两遍。
* 如果张伟已经被妈妈录进名册了,我再走「添加成员」录一遍,
* 系统里就会出现两条「张伟」—— 账号、亲属关系、生日各存一份,之后永远对不上。
* 所以这里把「这个家庭里已经有的人」列出来(带账号徽标,好认出是谁),
* 勾一个就把他**复用**进我的名册,一个字段都不用重填。
*
* 需求原话:「当一个用户添加一个名册的时候…如果这个人员信息在系统当中已经存在,
* 那么他可以直接复用这个人的信息,就不需要再重新录入。」
*/
?>
<div class="page-head">
<h1>从家里已有的人里挑</h1>
<span class="sub">选中的会进你的名册,资料共用一份,不会重复录入</span>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('people')) ?>">回名册</a>
</div>
</div>
<?php if (!$people): ?>
<div class="empty">
<b>没有可挑的人了</b>
家里已经有的人,都在你的名册里了。要加新人就点「添加成员」。
</div>
<?php else: ?>
<form method="post" action="<?= e(url('people/pick')) ?>">
<?= csrf_field() ?>
<div class="card card-pad">
<p class="small muted" style="margin:0 0 14px">
这些人都已经在这个家庭里(可能是别人先录的)。勾上就会出现在你的名册里 ——
<b>资料是共用的那一份</b>,你以后改的也是同一份。
</p>
<div class="field" style="max-width:280px;margin-bottom:16px">
<label for="pickGroup">加进哪一组</label>
<select class="select" id="pickGroup" name="group_id">
<option value="">先不分组(之后拖到那一栏也行)</option>
<?php foreach ($groups as $g): $gid = (int)$g['id']; ?>
<option value="<?= $gid ?>"><?= e((string)$g['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<ul class="pick-list">
<?php foreach ($people as $p): $pid = (int)$p['id']; ?>
<?php
// 显示名优先用「我怎么称呼他」:别人录的名字未必是我对他的叫法
$shown = (string)($p['my_nickname'] ?? '');
if ($shown === '') {
$shown = (string)$p['name'];
}
?>
<li class="pick-row">
<label class="pick-item">
<input type="checkbox" name="ids[]" value="<?= $pid ?>">
<img class="pick-ph" src="<?= e(avatar_url($p['avatar'])) ?>" alt="">
<span class="pick-name">
<?= e($shown) ?>
<?php if ($shown !== (string)$p['name']): ?>
<span class="muted small">· 本名 <?= e((string)$p['name']) ?></span>
<?php endif; ?>
<?php if (!empty($p['is_placeholder'])): ?><span class="badge">待确认</span><?php endif; ?>
<?php
$r = (string)($p['account_role'] ?? '');
if ($r === 'admin' && ($p['account_status'] ?? '') === 'active') {
echo '<span class="badge badge-admin">管理员</span>';
} elseif ($r === 'guest') {
echo '<span class="badge">访客</span>';
} elseif ($r !== '') {
echo '<span class="badge badge-open">已开通</span>';
}
?>
</span>
</label>
</li>
<?php endforeach; ?>
</ul>
<div class="btn-row">
<button class="btn" type="submit">加进我的名册</button>
<a class="btn btn-ghost" href="<?= e(url('people')) ?>">取消</a>
</div>
</div>
</form>
<?php endif; ?>
+71 -20
View File
@@ -40,9 +40,7 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' =
<div class="page-head"> <div class="page-head">
<h1><?= e($pShown) ?></h1> <h1><?= e($pShown) ?></h1>
<span class="sub"> <span class="sub">
<?php if ($isSelf): ?> <?php if (!$isSelf): ?>
这是你自己
<?php else: ?>
<span class="rel-label"><?= e($labelToMe) ?></span> <span class="rel-label"><?= e($labelToMe) ?></span>
<?php if ($isMyNick): ?> <?php if ($isMyNick): ?>
· 本名 <?= e((string)$p['name']) ?> · 本名 <?= e((string)$p['name']) ?>
@@ -51,11 +49,10 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' =
<?php if (!empty($p['is_placeholder'])): ?><span class="badge">虚拟人物</span><?php endif; ?> <?php if (!empty($p['is_placeholder'])): ?><span class="badge">虚拟人物</span><?php endif; ?>
</span> </span>
<div class="actions"> <div class="actions">
<?php if ($isSelf): ?> <?php if ($isSelf || $canW): ?>
<a class="btn btn-sm" href="<?= e(url('settings/me')) ?>#me-profile">改我自己的资料</a> <a class="btn btn-ghost btn-sm" href="<?= e(url('people/edit', ['id' => $pid])) ?>">
<?php endif; ?> <?= $isSelf ? '编辑我的资料' : '编辑资料' ?>
<?php if ($canW): ?> </a>
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/edit', ['id' => $pid])) ?>">编辑资料</a>
<?php endif; ?> <?php endif; ?>
<?php if ($admin): ?> <?php if ($admin): ?>
<form method="post" action="<?= e(url('people/destroy')) ?>" style="display:inline" <form method="post" action="<?= e(url('people/destroy')) ?>" style="display:inline"
@@ -362,6 +359,57 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' =
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- ============ 名册归属 / 分享名片 ============ -->
<?php if (!$isSelf): ?>
<div class="card card-pad" style="margin-top:16px">
<h2 class="section-title">名册 <span class="hint">名册是每人一份的</span></h2>
<p class="small muted" style="margin:0 0 14px">
<?php if (!empty($inMyRoster)): ?>
「<?= e($pShown) ?>」在你的名册里。归到哪一组、排第几,只影响你自己看到的名册 ——
家里其他人各有一份,互不打搅。
<?php else: ?>
「<?= e($pShown) ?>」还不在你的名册里。你仍然能在家谱与首页看到他,
但名册只列<b>你自己加的人</b>和<b>别人分享给你的名片</b>。
<?php endif; ?>
</p>
<div class="btn-row" style="margin-top:0;flex-wrap:wrap;align-items:center">
<?php if (empty($inMyRoster) && $canW): ?>
<form method="post" action="<?= e(url('people/pick')) ?>" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="ids[]" value="<?= $pid ?>">
<button class="btn btn-sm" type="submit">加进我的名册</button>
</form>
<?php elseif (!empty($inMyRoster) && $canW): ?>
<form method="post" action="<?= e(url('people/forget')) ?>" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>">
<button class="btn btn-ghost btn-sm" type="submit">从我的名册里移走</button>
</form>
<?php endif; ?>
<?php if ($canW && !empty($options)): ?>
<?php /* 需求原话:「我可以把一个人的名片,分享给一个用户,
则这个名片,自动出现在这个用户的名册中」。 */ ?>
<details class="share-wrap">
<summary class="btn btn-ghost btn-sm">分享他的名片给…</summary>
<form method="post" action="<?= e(url('people/share')) ?>" class="inline-form">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>">
<input type="hidden" name="back" value="<?= $pid ?>">
<label class="sr-only" for="shareTo">分享给谁</label>
<select class="select" id="shareTo" name="to_id">
<?php foreach ($options as $o): $oid = (int)$o['id']; ?>
<option value="<?= $oid ?>"><?= e((string)$o['name']) ?></option>
<?php endforeach; ?>
</select>
<button class="btn btn-sm" type="submit">分享</button>
</form>
</details>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php if ($admin): ?> <?php if ($admin): ?>
<div class="card card-pad" style="margin-top:16px"> <div class="card card-pad" style="margin-top:16px">
<h2 class="section-title">登录账号</h2> <h2 class="section-title">登录账号</h2>
@@ -396,17 +444,22 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' =
<?= $role === 'guest' ? '—— 只能查看,不能新增或修改任何内容。' : '' ?> <?= $role === 'guest' ? '—— 只能查看,不能新增或修改任何内容。' : '' ?>
</p> </p>
<div class="btn-row" style="margin-top:0;flex-wrap:wrap"> <div class="btn-row" style="margin-top:0;flex-wrap:wrap">
<form method="post" action="<?= e(url('accounts/role')) ?>" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>">
<select class="select" name="role" style="width:auto;display:inline-block">
<option value="member" <?= $role === 'member' ? 'selected' : '' ?>>家庭成员</option>
<option value="admin" <?= $role === 'admin' ? 'selected' : '' ?>>管理员</option>
<option value="guest" <?= $role === 'guest' ? 'selected' : '' ?>>访客</option>
</select>
<button class="btn btn-ghost btn-sm" type="submit">改角色</button>
<?php if ($isSelf): ?>
<?php /* 自己也能改自己的权限(哪怕给自己降级)—— 系统的家可能就一个人用,
难道他还不能调自己的权限?唯一拦的是"降完一个管理员都不剩"。 */ ?>
<span class="small muted">可以改自己的。只有「把最后一位管理员降级」会被拦下。</span>
<?php endif; ?>
</form>
<?php if (!$isSelf): ?> <?php if (!$isSelf): ?>
<form method="post" action="<?= e(url('accounts/role')) ?>" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>">
<select class="select" name="role" style="width:auto;display:inline-block">
<option value="member" <?= $role === 'member' ? 'selected' : '' ?>>家庭成员</option>
<option value="admin" <?= $role === 'admin' ? 'selected' : '' ?>>管理员</option>
<option value="guest" <?= $role === 'guest' ? 'selected' : '' ?>>访客</option>
</select>
<button class="btn btn-ghost btn-sm" type="submit">改角色</button>
</form>
<form method="post" action="<?= e(url('accounts/status')) ?>" style="display:inline"> <form method="post" action="<?= e(url('accounts/status')) ?>" style="display:inline">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>"> <input type="hidden" name="person_id" value="<?= $pid ?>">
@@ -415,8 +468,6 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' =
<?= $p['account_status'] === 'active' ? '停用账号' : '启用账号' ?> <?= $p['account_status'] === 'active' ? '停用账号' : '启用账号' ?>
</button> </button>
</form> </form>
<?php else: ?>
<span class="small muted">这是你自己 —— 要改自己的权限,请让另一位管理员来操作。</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
+92
View File
@@ -31,6 +31,98 @@
</div> </div>
</form> </form>
<?php if (($upgradeWarn ?? '') !== ''): ?>
<div class="flash" style="margin-top:16px">
<div class="flash-item err">
<b>上次结构升级有步骤失败了</b>(其余步骤已经跑完,站点可用):<br>
<?= e((string)$upgradeWarn) ?><br>
<span class="muted small">修好之后可以清掉这条提示。</span>
</div>
</div>
<?php endif; ?>
<!-- ==================== 公共分组 ==================== -->
<?php
$pubGroups = $pubGroups ?? [];
$pubCounts = $pubCounts ?? [];
$pubTotal = count($pubGroups);
?>
<div class="card card-pad" style="margin-top:20px" id="public-groups">
<h2 class="section-title">
公共分组
<span class="hint">全家人共用的一套</span>
</h2>
<p class="small muted" style="margin:0 0 16px">
名册按「关系分组」分栏显示。这里维护的是<b>公共的那一套</b> ——
建一次,全家每个人都能拿来用。
<br>
每个人还可以在「个人设置 → 我的分组」里<b>再加只给自己看的分组</b>,
那部分不在这儿、也不会影响别人。挪顺序用 ▲ ▼(▲▼ 调的是"全家的默认顺序",
各人仍可在个人设置里改成自己习惯的顺序)。
<br>
删掉一个分组<b>不会删掉人</b>:名册里归到那一组的人会变成「未分类」,
各自重新归类即可。
</p>
<?php if ($pubGroups): ?>
<ol class="order-list">
<?php foreach ($pubGroups as $i => $g): $gid = (int)$g['id']; ?>
<li class="order-row">
<span class="order-no"><?= $i + 1 ?></span>
<span class="order-name"><?= e((string)$g['name']) ?></span>
<span class="order-count"><?= (int)($pubCounts[$gid] ?? 0) ?> 人</span>
<form method="post" action="<?= e(url('settings/groups/move')) ?>" class="order-move">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $gid ?>">
<input type="hidden" name="scope" value="public">
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="up"
title="上移一组" <?= $i === 0 ? 'disabled' : '' ?>>▲</button>
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="down"
title="下移一组" <?= $i === $pubTotal - 1 ? 'disabled' : '' ?>>▼</button>
</form>
</li>
<?php endforeach; ?>
</ol>
<?php endif; ?>
<form method="post" action="<?= e(url('settings/groups/add')) ?>" class="inline-form" style="margin-top:16px">
<?= csrf_field() ?>
<input type="hidden" name="scope" value="public">
<label class="sr-only" for="newPubGroup">新分组名</label>
<input class="input" id="newPubGroup" name="name" maxlength="20" placeholder="例如:邻居、亲家、老同事">
<button class="btn btn-sm" type="submit">加一个公共分组</button>
</form>
<?php if ($pubGroups): ?>
<ul class="group-manage" style="margin-top:14px">
<?php foreach ($pubGroups as $g): $gid = (int)$g['id']; ?>
<li class="group-manage-row">
<form method="post" action="<?= e(url('settings/groups/rename')) ?>" class="group-rename">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $gid ?>">
<input type="hidden" name="scope" value="public">
<label class="sr-only" for="pgname<?= $gid ?>">分组名</label>
<input class="input" id="pgname<?= $gid ?>" name="name" maxlength="20"
value="<?= e((string)$g['name']) ?>">
<button class="btn btn-ghost btn-sm" type="submit">改名</button>
</form>
<span class="group-count muted small"><?= (int)($pubCounts[$gid] ?? 0) ?> 人</span>
<details class="del-wrap">
<summary class="btn btn-ghost btn-sm">删除</summary>
<form method="post" action="<?= e(url('settings/groups/delete')) ?>" class="del-confirm">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $gid ?>">
<input type="hidden" name="scope" value="public">
<span class="small muted">这组的人会变成「未分类」,确定?</span>
<button class="btn btn-ghost btn-sm danger" type="submit">确认删除</button>
</form>
</details>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<!-- ==================== 附件存储 ==================== --> <!-- ==================== 附件存储 ==================== -->
<form method="post" action="<?= e(url('settings/storage')) ?>" class="card card-pad" style="margin-top:20px"> <form method="post" action="<?= e(url('settings/storage')) ?>" class="card card-pad" style="margin-top:20px">
<?= csrf_field() ?> <?= csrf_field() ?>
-153
View File
@@ -1,153 +0,0 @@
<?php
/**
* 「我的资料」表单(个人设置的第一块)。
*
* 只放**属于我自己的**东西:
* · 我的姓名、性别、头像、联系方式、备注
* · 我的三个生日(身份证生日只读,由证件推导)
* · 我的 slogan(没填就用家庭统一的那句)
* 排行、排序号、虚拟人物标记、以及会牵动身份证生日的身份证号,
* 都属于"管理员编名册",不在这里(这也是为了不让一次自助提交悄悄改掉这些)。
*
* 作为独立片段是因为「个人设置」页还要接上称呼、分组顺序、分类管理三块,
* 全塞一个文件里会变成没人愿意读的六百行。
*/
$p = $person;
$avatarUrl = avatar_url($p['avatar'] ?? null);
$bdSource = old('birthday_source', 'solar');
$idBd = trim((string)($p['birthday_id_card'] ?? ''));
// 裁剪比例由「这个位置显示成什么形状」决定,前后端共用同一份配置(见 App\ImageSlot)
$avSlot = \App\ImageSlot::get('avatar');
?>
<form method="post" enctype="multipart/form-data" action="<?= e(url('settings/me_save')) ?>">
<?= csrf_field() ?>
<div class="card card-pad">
<div class="form-grid">
<div class="field">
<label for="name">姓名 *</label>
<input class="input" id="name" name="name" value="<?= e(old('name', $p['name'])) ?>" required>
<?php if (!empty($errors['name'])): ?><span class="err"><?= e($errors['name']) ?></span><?php endif; ?>
</div>
<div class="field">
<label for="gender">性别</label>
<select class="select" id="gender" name="gender">
<?php $g = old('gender', $p['gender'] ?? ''); ?>
<option value="" <?= $g === '' ? 'selected' : '' ?>>不填</option>
<option value="m" <?= $g === 'm' ? 'selected' : '' ?>>男</option>
<option value="f" <?= $g === 'f' ? 'selected' : '' ?>>女</option>
</select>
</div>
<div class="field full">
<label for="slogan">我的一句话</label>
<input class="input" id="slogan" name="slogan" maxlength="200"
value="<?= e(old('slogan', $p['slogan'] ?? '')) ?>"
placeholder="留空就用家庭统一的那句:<?= e($familyMotto !== '' ? $familyMotto : '(还没设)') ?>">
<span class="tip">显示在你自己的首页标题下面。</span>
<?php if (!empty($errors['slogan'])): ?><span class="err"><?= e($errors['slogan']) ?></span><?php endif; ?>
</div>
<!-- ==================== 我的三个生日 ==================== -->
<div class="field full">
<label>我的生日</label>
<p class="small muted" style="margin:0 0 12px">
身份证上的那个由证件推导,<b>系统永远不会改它</b>;
下面两个是"实际过的阳历生日"和"长辈记的农历生日"。
改其中任意一个,另一个会自动跟着变。
</p>
<div class="field" style="margin-bottom:12px">
<label>身份证生日(只读)</label>
<span class="bd-readonly"><?= $idBd !== '' ? e($idBd) : '名册里还没填身份证号' ?></span>
</div>
<div class="bd-source">
<label for="bdSource">我这次改的是</label>
<select class="select" id="bdSource" name="birthday_source" style="width:auto">
<option value="solar" <?= $bdSource === 'solar' ? 'selected' : '' ?>>阳历(公历)</option>
<option value="lunar" <?= $bdSource === 'lunar' ? 'selected' : '' ?>>农历(阴历)</option>
</select>
<span class="small muted">选中的那一边为准,另一边自动算</span>
</div>
<div class="bd-pane" id="bdSolarWrap">
<p class="bd-pane-k">阳历生日</p>
<?php
$dtName = 'birthday_solar';
$dtValue = old('birthday_solar', \App\Birthday::pretty(\App\Birthday::solarOf($p)));
$dtId = 'birthday_solar_date';
$dtRequired = false;
require __DIR__ . '/../_datetime.php';
?>
<?php if (!empty($errors['birthday_solar'])): ?><span class="err"><?= e($errors['birthday_solar']) ?></span><?php endif; ?>
</div>
<div class="bd-pane" id="bdLunarWrap">
<p class="bd-pane-k">农历生日</p>
<?php
$dtName = 'birthday_lunar';
$dtValue = old('birthday_lunar', (string)($p['birthday_lunar'] ?? ''));
$dtId = 'birthday_lunar_date';
$dtRequired = false;
require __DIR__ . '/../_datetime.php';
?>
<label class="check-item" style="margin-top:8px">
<input type="checkbox" name="birthday_lunar_leap" value="1"
<?= old('birthday_lunar_leap', $p['birthday_lunar_leap'] ?? 0) ? 'checked' : '' ?>>
<span>这个月是<b>闰月</b>(如「闰六月」)</span>
</label>
<?php if (!empty($errors['birthday_lunar'])): ?><span class="err"><?= e($errors['birthday_lunar']) ?></span><?php endif; ?>
</div>
</div>
<div class="field">
<label for="email">邮箱</label>
<input class="input" id="email" name="email" type="email"
value="<?= e(old('email', $p['email'] ?? '')) ?>">
<?php if (!empty($errors['email'])): ?><span class="err"><?= e($errors['email']) ?></span><?php endif; ?>
</div>
<div class="field">
<label for="address">住址</label>
<input class="input" id="address" name="address"
value="<?= e(old('address', $p['address'] ?? '')) ?>">
</div>
<div class="field full">
<label for="note">备注</label>
<textarea class="input" id="note" name="note"><?= e(old('note', $p['note'] ?? '')) ?></textarea>
</div>
<div class="field">
<label for="avatar">换头像</label>
<input class="input" id="avatar" name="avatar" type="file" accept="image/*"
data-crop-ratio="<?= e((string)$avSlot['ratio']) ?>"
data-crop-w="<?= (int)$avSlot['w'] ?>"
data-crop-h="<?= (int)$avSlot['h'] ?>"
data-crop-label="<?= e((string)$avSlot['label']) ?>"
data-crop-hint="<?= e((string)$avSlot['hint']) ?>"
data-crop-preview="#avatar-preview">
<?php /* 用户挑的裁剪框(归一化 0..1,相对原图)。不裁/没 JS 就留空 = 居中裁切 */ ?>
<input type="hidden" name="avatar_crop" value="">
<span class="tip">
选好图片后会弹出裁剪框,把要留的部分框住。<b>比例按它在页面上的形状定</b>
(头像是 1:1 正方形)。上限 5MB。
</span>
<?php if (!empty($errors['avatar'])): ?><span class="err"><?= e($errors['avatar']) ?></span><?php endif; ?>
</div>
<div class="field">
<label>当前头像</label>
<img id="avatar-preview" src="<?= e($avatarUrl) ?>" alt=""
style="width:88px;height:88px;border-radius:12px;object-fit:cover;border:1px solid var(--line)">
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit">保存我的资料</button>
</div>
</div>
</form>
+63 -43
View File
@@ -3,23 +3,23 @@
* 个人设置 —— 「我自己的」设置。 * 个人设置 —— 「我自己的」设置。
* *
* 与「系统设置」(?r=settings,仅管理员)的分工: * 与「系统设置」(?r=settings,仅管理员)的分工:
* · 系统设置改一次**全家都变**:家庭 slogan、附件存储、运行环境; * · 系统设置改一次**全家都变**:slogan 兜底、附件存储、运行环境、**公共分组**;
* · 个人设置只影响**我自己看到的样子**:我的资料、我怎么称呼别人、 * · 个人设置只影响**我自己看到的样子**:我怎么称呼家人、我自己的分组。
* 名册里分组的先后顺序、以及关系分类的增删改名。
* *
* ⚠️ 关于「顺序」与「分类」这两个都不在系统设置里,理由是: * ⚠️ 「我的资料」表单**不在这页**(用户明确要求合并):
* 分类是全家共用的一套(老杨的同事,对全家来说都是同事),所以它写在共享的表里; * 「编辑成员」和「改自己的资料」本来就是一件事,所以只剩
* 但**哪一组排前面纯属个人习惯** —— 爸爸把「家人」排第一、妈妈把「亲戚」排第一, * `templates/people/form.php` 那一张表单,两个入口都指过去 ——
* 这种偏好存在 user_settings 里,跟 family 级的 settings 严格分开, * 这页顶部那个按钮,和名册卡片上的「编辑资料」,落到的完全是同一个页面。
* 免得"改一个人的偏好"误伤到全家。
*/ */
$groupCounts = $groupCounts ?? []; $groupCounts = $groupCounts ?? [];
$groupTotal = count($groups); $groupTotal = count($groups);
$rosterCount = $rosterCount ?? 0;
?> ?>
<div class="page-head"> <div class="page-head">
<h1>个人设置</h1> <h1>个人设置</h1>
<span class="sub">只影响你自己看到的样子</span> <span class="sub">只影响你自己看到的样子</span>
<div class="actions"> <div class="actions">
<a class="btn btn-sm" href="<?= e(url('people/edit', ['id' => (int)$person['id']])) ?>">编辑我的资料</a>
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/show', ['id' => (int)$person['id']])) ?>">看我的详情页</a> <a class="btn btn-ghost btn-sm" href="<?= e(url('people/show', ['id' => (int)$person['id']])) ?>">看我的详情页</a>
<?php if (\App\Auth::isAdmin()): ?> <?php if (\App\Auth::isAdmin()): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('settings')) ?>">系统设置</a> <a class="btn btn-ghost btn-sm" href="<?= e(url('settings')) ?>">系统设置</a>
@@ -27,36 +27,58 @@ $groupTotal = count($groups);
</div> </div>
</div> </div>
<h2 class="section-title" id="me-profile">我的资料 <span class="hint">姓名、头像、生日、联系方式</span></h2> <div class="card card-pad">
<?php require __DIR__ . '/_profile.php'; ?> <h2 class="section-title">我的资料 <span class="hint">姓名、头像、生日、联系方式</span></h2>
<p class="small muted" style="margin:0 0 14px">
<?= e((string)$person['name']) ?>
<?php if (trim((string)($person['slogan'] ?? '')) !== ''): ?>
· 「<?= e((string)$person['slogan']) ?>」
<?php endif; ?>
<br>
资料的编辑表单只有一张,和「编辑成员」是同一个页面 —— 从这里进去改的就是你自己。
</p>
<div class="btn-row" style="margin-top:0">
<a class="btn" href="<?= e(url('people/edit', ['id' => (int)$person['id']])) ?>">编辑我的资料</a>
<a class="btn btn-ghost" href="<?= e(url('auth/password')) ?>">改登录密码</a>
</div>
</div>
<div class="stack-gap"></div> <div class="stack-gap"></div>
<?php require __DIR__ . '/_nicknames.php'; ?> <?php require __DIR__ . '/_nicknames.php'; ?>
<!-- ==================== 名册分组的先后顺序 ==================== --> <!-- ==================== 我的分组 ==================== -->
<div class="card card-pad" style="margin-top:20px"> <div class="card card-pad" style="margin-top:20px">
<h2 class="section-title"> <h2 class="section-title">
名册分组顺序 我的分组
<span class="hint">只影响你自己看到的顺序</span> <span class="hint">公共的 + 我自己加的</span>
</h2> </h2>
<p class="small muted" style="margin:0 0 16px"> <p class="small muted" style="margin:0 0 16px">
名册是按「关系分组」分栏显示的。分组本身是全家共用的一套, 名册是按「关系分组」分栏显示的。这里有两层:
但<b>哪一组排前面是你自己的习惯</b> —— 你在这里调,别人看到的顺序不变。 <b>公共分组</b>由管理员在「系统设置」里维护(全家都能用);
<?php if ($groupTotal > 1): ?><br>用 ▲ ▼ 把它挪到你要的位置,挪一下就存一次。<?php endif; ?> 你要是觉得不够,就在下面<b>加只给自己看的分组</b>,别人看不到、也不受影响。
<br>
列表里 <b>{{ 公共 }}</b> 标的是大家共用的那几个。
哪一组排前面也是你自己的习惯,用 ▲ ▼ 调 —— 别人看到的顺序不变。
<br>
你的名册现在有 <b><?= (int)$rosterCount ?></b> 人。
</p> </p>
<?php if (!$groups): ?> <?php if (!$groups): ?>
<p class="small muted" style="margin:0">还没有分组。</p> <p class="small muted" style="margin:0 0 14px">还没有分组。加一个吧。</p>
<?php else: ?> <?php else: ?>
<ol class="order-list"> <ol class="order-list">
<?php foreach ($groups as $i => $g): $gid = (int)$g['id']; ?> <?php foreach ($groups as $i => $g): $gid = (int)$g['id']; $own = (int)$g['owner_id']; ?>
<li class="order-row"> <li class="order-row">
<span class="order-no"><?= $i + 1 ?></span> <span class="order-no"><?= $i + 1 ?></span>
<span class="order-name"><?= e((string)$g['name']) ?></span> <span class="order-name">
<?= e((string)$g['name']) ?>
<?php if ($own === 0): ?><span class="badge">公共</span><?php else: ?><span class="badge badge-open">我的</span><?php endif; ?>
</span>
<span class="order-count"><?= (int)($groupCounts[$gid] ?? 0) ?> 人</span> <span class="order-count"><?= (int)($groupCounts[$gid] ?? 0) ?> 人</span>
<form method="post" action="<?= e(url('settings/groups/move')) ?>" class="order-move"> <form method="post" action="<?= e(url('settings/groups/move')) ?>" class="order-move">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $gid ?>"> <input type="hidden" name="id" value="<?= $gid ?>">
<input type="hidden" name="scope" value="me">
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="up" <button class="btn btn-ghost btn-sm" type="submit" name="dir" value="up"
title="上移一组" <?= $i === 0 ? 'disabled' : '' ?>>▲</button> title="上移一组" <?= $i === 0 ? 'disabled' : '' ?>>▲</button>
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="down" <button class="btn btn-ghost btn-sm" type="submit" name="dir" value="down"
@@ -66,38 +88,29 @@ $groupTotal = count($groups);
<?php endforeach; ?> <?php endforeach; ?>
</ol> </ol>
<?php endif; ?> <?php endif; ?>
</div>
<!-- ==================== 关系分类的增删改名 ==================== --> <form method="post" action="<?= e(url('settings/groups/add')) ?>" class="inline-form" style="margin-top:16px">
<div class="card card-pad" style="margin-top:20px">
<h2 class="section-title">
关系分类管理
<span class="hint">分类是全家共用的</span>
</h2>
<p class="small muted" style="margin:0 0 16px">
预置了「家人 / 亲戚 / 同事 / 朋友 / 合作伙伴 / 认识 / 不熟」七个。
<b>不够用就自己加</b>,叫法不合适就改名。
分类是全家共用的一套(你加的,家里人都会看到)——
因为「老杨的同事」对全家来说都是同事,各人各定一套会让名册没法看。
<br>
删掉一个分类<b>不会删掉人</b>:那一组人会变成「未分类」,在名册里提醒你重新归类。
</p>
<form method="post" action="<?= e(url('settings/groups/add')) ?>" class="inline-form">
<?= csrf_field() ?> <?= csrf_field() ?>
<label class="sr-only" for="newGroup">新分类名</label> <input type="hidden" name="scope" value="me">
<input class="input" id="newGroup" name="name" maxlength="20" placeholder="例如:邻居、球友、老同学"> <label class="sr-only" for="newMyGroup">新分组名</label>
<button class="btn btn-sm" type="submit">加一个分类</button> <input class="input" id="newMyGroup" name="name" maxlength="20" placeholder="例如:球友、老同学">
<button class="btn btn-sm" type="submit">加一个我自己的分组</button>
</form> </form>
<?php if ($groups): ?> <?php
<ul class="group-manage"> // 只有我自己建的分组才给改名 / 删除 —— 公共分组要在系统设置里改(那是管理员的事),
<?php foreach ($groups as $g): $gid = (int)$g['id']; ?> // 免得有人在自己设置里把"家人"改成别的、影响全家。
$mineGroups = array_values(array_filter($groups, fn($g) => (int)$g['owner_id'] > 0));
?>
<?php if ($mineGroups): ?>
<ul class="group-manage" style="margin-top:14px">
<?php foreach ($mineGroups as $g): $gid = (int)$g['id']; ?>
<li class="group-manage-row"> <li class="group-manage-row">
<form method="post" action="<?= e(url('settings/groups/rename')) ?>" class="group-rename"> <form method="post" action="<?= e(url('settings/groups/rename')) ?>" class="group-rename">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $gid ?>"> <input type="hidden" name="id" value="<?= $gid ?>">
<label class="sr-only" for="gname<?= $gid ?>">分类名</label> <input type="hidden" name="scope" value="me">
<label class="sr-only" for="gname<?= $gid ?>">分组名</label>
<input class="input" id="gname<?= $gid ?>" name="name" maxlength="20" <input class="input" id="gname<?= $gid ?>" name="name" maxlength="20"
value="<?= e((string)$g['name']) ?>"> value="<?= e((string)$g['name']) ?>">
<button class="btn btn-ghost btn-sm" type="submit">改名</button> <button class="btn btn-ghost btn-sm" type="submit">改名</button>
@@ -109,6 +122,7 @@ $groupTotal = count($groups);
<form method="post" action="<?= e(url('settings/groups/delete')) ?>" class="del-confirm"> <form method="post" action="<?= e(url('settings/groups/delete')) ?>" class="del-confirm">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $gid ?>"> <input type="hidden" name="id" value="<?= $gid ?>">
<input type="hidden" name="scope" value="me">
<span class="small muted">这组的人会变成「未分类」,确定?</span> <span class="small muted">这组的人会变成「未分类」,确定?</span>
<button class="btn btn-ghost btn-sm danger" type="submit">确认删除</button> <button class="btn btn-ghost btn-sm danger" type="submit">确认删除</button>
</form> </form>
@@ -117,4 +131,10 @@ $groupTotal = count($groups);
<?php endforeach; ?> <?php endforeach; ?>
</ul> </ul>
<?php endif; ?> <?php endif; ?>
<?php if (\App\Auth::isAdmin()): ?>
<p class="small muted" style="margin:16px 0 0">
公共分组在「<a href="<?= e(url('settings')) ?>#public-groups">系统设置</a>」里维护。
</p>
<?php endif; ?>
</div> </div>