From ed38a40c08d437a63dd29aebed1b0e765221e8b3 Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 30 Sep 2026 13:24:26 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8D=E5=86=8C=E6=AF=8F=E4=BA=BA=E4=B8=80?= =?UTF-8?q?=E4=BB=BD=20=C2=B7=20=E5=88=86=E7=BB=84=E4=B8=A4=E5=B1=82=20?= =?UTF-8?q?=C2=B7=20=E6=8B=96=E5=8A=A8=E6=8E=92=E5=BA=8F=20=C2=B7=20?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E5=90=88=E4=B8=80=20=C2=B7=20=E8=A3=81?= =?UTF-8?q?=E5=89=AA=E5=8F=AF=E7=BC=A9=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按用户十点反馈逐条落地(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)。 --- app/Account.php | 25 +- app/Auth.php | 2 +- app/Nickname.php | 2 +- app/Person.php | 74 ++++-- app/PersonGroup.php | 248 ++++++++++++------- app/PersonRoster.php | 311 +++++++++++++++++++++++ app/Schema.php | 175 +++++++++++-- assets/css/app.css | 74 +++++- assets/js/app.js | 258 ++++++++++++++++++- index.php | 425 +++++++++++++++++++++++--------- install.php | 10 +- templates/home.php | 3 - templates/kin/tree.php | 15 -- templates/layout.php | 5 +- templates/people/form.php | 58 +++-- templates/people/index.php | 159 ++++++++---- templates/people/pick.php | 88 +++++++ templates/people/show.php | 91 +++++-- templates/settings.php | 92 +++++++ templates/settings/_profile.php | 153 ------------ templates/settings/me.php | 106 ++++---- 21 files changed, 1780 insertions(+), 594 deletions(-) create mode 100644 app/PersonRoster.php create mode 100644 templates/people/pick.php delete mode 100644 templates/settings/_profile.php diff --git a/app/Account.php b/app/Account.php index 171c522..b887bfc 100644 --- a/app/Account.php +++ b/app/Account.php @@ -26,7 +26,30 @@ final class Account 'INSERT INTO accounts (person_id, password_hash, role) VALUES (?, ?, ?)' ); $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 diff --git a/app/Auth.php b/app/Auth.php index 1681b04..7c6e744 100644 --- a/app/Auth.php +++ b/app/Auth.php @@ -68,7 +68,7 @@ final class Auth /** * 首页标题 —— 「以我为中心」最直接的一处落点。 - * 登录后看到的不是「家书」,而是「老杨的家书」。 + * 登录后看到的不是「家书」,而是「<我的名字>的家书」。 */ public static function homeTitle(): string { diff --git a/app/Nickname.php b/app/Nickname.php index 1ff4d3c..b8307b4 100644 --- a/app/Nickname.php +++ b/app/Nickname.php @@ -149,7 +149,7 @@ final class Nickname '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 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]); return $st->fetchAll(); diff --git a/app/Person.php b/app/Person.php index f59e2b1..f9222dc 100644 --- a/app/Person.php +++ b/app/Person.php @@ -41,7 +41,44 @@ final class Person $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->execute($args); return $st->fetchAll(); @@ -91,8 +128,8 @@ final class Person (family_id, kind, name, gender, birth_rank, birthday, birthday_id_card, birthday_lunar, birthday_lunar_leap, slogan, avatar, phone_hash, phone_encrypted, id_card_encrypted, email, address, note, - is_placeholder, group_id, sort_order) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'; + is_placeholder) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'; $st = Database::get()->prepare($sql); $st->execute([ $familyId, @@ -113,8 +150,6 @@ final class Person ($d['address'] ?? '') !== '' ? $d['address'] : null, ($d['note'] ?? '') !== '' ? $d['note'] : null, !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(); } @@ -127,7 +162,7 @@ final class Person name = ?, gender = ?, birth_rank = ?, birthday = ?, birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?, phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?, - email = ?, address = ?, note = ?, is_placeholder = ?, group_id = ?, sort_order = ? + email = ?, address = ?, note = ?, is_placeholder = ? WHERE id = ?'; Database::get()->prepare($sql)->execute([ $d['name'], @@ -146,32 +181,19 @@ final class Person ($d['address'] ?? '') !== '' ? $d['address'] : null, ($d['note'] ?? '') !== '' ? $d['note'] : null, !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, ]); } /** - * 只改「这个人属于哪一组」(名册上的快捷归类用)。 + * 自助改自己的资料。 * - * 单独一个方法,是为了让名册那一小块表单**不必**把整条记录回传 —— - * 否则一次归类提交就要带上姓名/生日/身份证号,任何一处没带全就会把数据改坏。 - */ - public static function setGroup(int $id, ?int $groupId): void - { - Database::get()->prepare('UPDATE persons SET group_id = ? WHERE id = ?') - ->execute([$groupId ?: null, $id]); - } - - /** - * 自助改自己的资料(个人信息页用)。 - * - * 与上面的 update() 分开是有意的:那个是"管理员编名册",字段全、权限高; + * 与上面的 update() 分开是有意的:那个是"能写的人编名册",字段全、权限高; * 这个只允许改**属于自己的**东西 —— 姓名、性别、三个生日、一句话、头像、联系方式。 - * 排行、排序号、虚拟人物标记、以及身份证号(会牵动身份证生日)都只能由管理员改名册时动。 + * 排行、虚拟人物标记、以及身份证号(会牵动身份证生日)都只能由管理员改名册时动。 + * + * ⚠️ v5 起这里**不再有**分组:分组是「我和他的关系」,挂在 person_roster 上, + * 只能在名册里拖或者在那张卡片的「调整分组」里改(见 PersonRoster::assignGroup)。 */ public static function updateSelf(int $id, array $d): void { @@ -230,7 +252,7 @@ final class Person FROM persons p 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 <> ? - ORDER BY p.sort_order ASC, p.id ASC' + ORDER BY p.id ASC' ); $st->execute([$viewerId, $familyId, $exceptId]); return $st->fetchAll(); diff --git a/app/PersonGroup.php b/app/PersonGroup.php index 2513cbe..184a6fc 100644 --- a/app/PersonGroup.php +++ b/app/PersonGroup.php @@ -1,17 +1,18 @@ prepare( - 'INSERT IGNORE INTO person_groups (family_id, name, position, is_builtin) VALUES (?, ?, ?, 1)' + 'INSERT IGNORE INTO person_groups (family_id, owner_id, name, position, is_builtin) + VALUES (?, ?, ?, ?, 1)' ); foreach (Schema::DEFAULT_GROUPS as $name => $pos) { - $st->execute([$familyId, $name, $pos]); + $st->execute([$familyId, max(0, $ownerId), $name, $pos]); } } - /** 全部分组,id => 行(按默认顺序) */ + /** 某一套分组(公共传 0,个人传自己的 person_id),id => 行,按默认顺序 */ + public static function forOwner(int $familyId, int $ownerId): array + { + $st = Database::get()->prepare( + 'SELECT * FROM person_groups WHERE family_id = ? AND owner_id = ? + ORDER BY position ASC, id ASC' + ); + $st->execute([$familyId, max(0, $ownerId)]); + $out = []; + foreach ($st->fetchAll() as $r) { + $out[(int)$r['id']] = $r; + } + return $out; + } + + /** 这个家庭里的全部分组(含所有人的私自分组)—— 只用来校验"这个 id 是本家庭的" */ public static function byId(int $familyId): array { $st = Database::get()->prepare( @@ -54,6 +74,34 @@ final class PersonGroup return $out; } + /** + * **这个人在名册里能用哪些分组** = 公共的 + 他自己的私自分组。 + * + * 访客 / 还没定过个人分组的人也至少能拿到公共的那一套, + * 所以"管理员建了分组但没人用得上"不会发生。 + */ + public static function visible(int $familyId, int $viewerId): array + { + $viewerId = max(0, $viewerId); + $st = Database::get()->prepare( + 'SELECT * FROM person_groups + WHERE family_id = ? AND (owner_id = 0 OR owner_id = ?) + ORDER BY owner_id ASC, position ASC, id ASC' + ); + $st->execute([$familyId, $viewerId]); + $out = []; + foreach ($st->fetchAll() as $r) { + $out[(int)$r['id']] = $r; + } + return $out; + } + + /** visible() 的 id 集合版本,用来做「这个分组 id 合不合法」的判断 */ + public static function visibleIds(int $familyId, int $viewerId): array + { + return array_fill_keys(array_keys(self::visible($familyId, $viewerId)), true); + } + /** * 按**当前用户自定义的顺序**返回分组(用户没自定义过就按默认位置)。 * @@ -62,7 +110,7 @@ final class PersonGroup */ public static function orderFor(int $familyId, int $viewerId = 0): array { - $rest = self::byId($familyId); + $rest = self::visible($familyId, $viewerId); if (!$rest) { return []; } @@ -90,7 +138,7 @@ final class PersonGroup /** 保存「我」的分组顺序(只影响我自己) */ public static function saveOrder(int $familyId, int $viewerId, array $ids): void { - $valid = self::byId($familyId); + $valid = self::visible($familyId, $viewerId); $clean = []; foreach ($ids as $id) { $id = (int)$id; @@ -107,6 +155,20 @@ final class PersonGroup UserSetting::set($viewerId, 'group_order', json_encode($clean, JSON_UNESCAPED_UNICODE)); } + /** 按给定的 id 顺序重写某一套分组的 position(公共分组的「全家默认顺序」靠它) */ + public static function savePositions(int $familyId, int $ownerId, array $ids): void + { + $db = Database::get(); + $up = $db->prepare( + 'UPDATE person_groups SET position = ? WHERE family_id = ? AND owner_id = ? AND id = ?' + ); + $pos = 0; + foreach ($ids as $id) { + $pos += 10; + $up->execute([$pos, $familyId, max(0, $ownerId), (int)$id]); + } + } + /** 分组名(找不到就返回空串,调用方自己决定回退文案) */ public static function name(int $familyId, ?int $id): string { @@ -120,81 +182,107 @@ final class PersonGroup /** * 新增一个分组。 + * @param int $ownerId 0=公共(只有管理员能建,调用方把关);否则=建给自己的 * @return array{ok:bool,id?:int,error?:string} */ - public static function add(int $familyId, string $name): array + public static function add(int $familyId, string $name, int $ownerId = self::PUBLIC_OWNER): array { + $ownerId = max(0, $ownerId); $name = trim($name); $bad = self::validateName($name); if ($bad !== '') { return ['ok' => false, 'error' => $bad]; } - $st = Database::get()->prepare( - 'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND name = ?' + $db = Database::get(); + $st = $db->prepare( + 'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND owner_id = ? AND name = ?' ); - $st->execute([$familyId, $name]); + $st->execute([$familyId, $ownerId, $name]); if ((int)$st->fetchColumn() > 0) { return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。']; } // ⚠️ 别写成 `$max = (int)$db->prepare(...)->execute([...])`: // PDOStatement::execute() 返回 bool,强转永远是 1 —— 位置会算错。 - $st2 = Database::get()->prepare('SELECT COALESCE(MAX(position), 0) FROM person_groups WHERE family_id = ?'); - $st2->execute([$familyId]); + $st2 = $db->prepare( + 'SELECT COALESCE(MAX(position), 0) FROM person_groups WHERE family_id = ? AND owner_id = ?' + ); + $st2->execute([$familyId, $ownerId]); $max = (int)$st2->fetchColumn(); - Database::get()->prepare( - 'INSERT INTO person_groups (family_id, name, position, is_builtin) VALUES (?, ?, ?, 0)' - )->execute([$familyId, $name, max($max + 10, 80)]); - return ['ok' => true, 'id' => (int)Database::get()->lastInsertId()]; + $db->prepare( + 'INSERT INTO person_groups (family_id, owner_id, name, position, is_builtin) + VALUES (?, ?, ?, ?, 0)' + )->execute([$familyId, $ownerId, $name, max($max + 10, 80)]); + return ['ok' => true, 'id' => (int)$db->lastInsertId()]; } - /** @return array{ok:bool,error?:string} */ - public static function rename(int $familyId, int $id, string $name): array + /** + * 改名。$ownerId 用来确认"你改的是你自己那一套里的分组"—— + * 公共分组只有管理员能改(调用方把关),别人的私自分组连管理员也不该动。 + * @return array{ok:bool,error?:string} + */ + public static function rename(int $familyId, int $id, string $name, int $ownerId): array { + $ownerId = max(0, $ownerId); $name = trim($name); $bad = self::validateName($name); if ($bad !== '') { return ['ok' => false, 'error' => $bad]; } - $st = Database::get()->prepare( - 'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND name = ? AND id <> ?' + $db = Database::get(); + $own = $db->prepare( + 'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND owner_id = ? AND id = ?' ); - $st->execute([$familyId, $name, $id]); + $own->execute([$familyId, $ownerId, $id]); + if ((int)$own->fetchColumn() === 0) { + return ['ok' => false, 'error' => '找不到这个分组(可能不是你那一套里的)。']; + } + $st = $db->prepare( + 'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND owner_id = ? AND name = ? AND id <> ?' + ); + $st->execute([$familyId, $ownerId, $name, $id]); if ((int)$st->fetchColumn() > 0) { return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。']; } - $st = Database::get()->prepare('UPDATE person_groups SET name = ? WHERE family_id = ? AND id = ?'); - $st->execute([$name, $familyId, $id]); - if ($st->rowCount() === 0) { + $up = $db->prepare( + 'UPDATE person_groups SET name = ? WHERE family_id = ? AND owner_id = ? AND id = ?' + ); + $up->execute([$name, $familyId, $ownerId, $id]); + if ($up->rowCount() === 0) { return ['ok' => false, 'error' => '找不到这个分组。']; } return ['ok' => true]; } /** - * 删除一个分组。挂在这个分组下的人**不会消失**,只是变成「未分类」—— - * 名册上会多出一个「未分类」组提醒去归类。 - * 最后一个分组不给删:全删光就没有任何分类可用了,那是死路。 + * 删除一个分组。名册里挂在这个分组下的人**不会消失**,只是变成「未分类」—— + * 名册上会多出一个「未分类」栏提醒去归类。 + * + * 最后一个**公共**分组不给删:全删光就没有任何分类可用了,那是死路。 + * 私自分组可以删到零(公共那套还在,不会没得选)。 * * @return array{ok:bool,error?:string,moved?:int} */ - public static function delete(int $familyId, int $id): array + public static function delete(int $familyId, int $id, int $ownerId): array { - $all = self::byId($familyId); + $ownerId = max(0, $ownerId); + $all = self::forOwner($familyId, $ownerId); if (!isset($all[$id])) { - return ['ok' => false, 'error' => '找不到这个分组。']; + return ['ok' => false, 'error' => '找不到这个分组(可能不是你那一套里的)。']; } - if (count($all) <= 1) { - return ['ok' => false, 'error' => '至少要留一个分组 —— 全删光就没法给成员归类了。']; + if ($ownerId === self::PUBLIC_OWNER && count($all) <= 1) { + return ['ok' => false, 'error' => '至少要留一个公共分组 —— 全删光就没法给成员归类了。']; } $db = Database::get(); - $st = $db->prepare('SELECT COUNT(*) FROM persons WHERE family_id = ? AND group_id = ?'); - $st->execute([$familyId, $id]); + $st = $db->prepare('SELECT COUNT(*) FROM person_roster WHERE group_id = ?'); + $st->execute([$id]); $moved = (int)$st->fetchColumn(); - $db->prepare('UPDATE persons SET group_id = NULL WHERE family_id = ? AND group_id = ?') - ->execute([$familyId, $id]); - $db->prepare('DELETE FROM person_groups WHERE family_id = ? AND id = ?') - ->execute([$familyId, $id]); + // ⚠️ 释放范围是**所有**名册条目(不限 owner):公共分组人人都能用, + // 只清自己那几条会让别人名册里留下指向已删分组的 id(那一栏就永远空着、 + // 而对应的卡片在同一次渲染里彻底消失)。 + $db->prepare('UPDATE person_roster SET group_id = NULL WHERE group_id = ?')->execute([$id]); + $db->prepare('DELETE FROM person_groups WHERE family_id = ? AND owner_id = ? AND id = ?') + ->execute([$familyId, $ownerId, $id]); // 各人自定义顺序里可能留着这个 id,清掉免得越积越脏 self::pruneOrder($familyId, $id); @@ -229,56 +317,38 @@ final class PersonGroup continue; } $keep = array_values(array_filter($ids, fn($x) => (int)$x !== $removedId)); - $db->prepare('UPDATE user_settings SET v = ? WHERE person_id = ? AND k = ?') - ->execute([json_encode(array_map('intval', $keep)), (int)$row['person_id'], 'group_order']); + $prep = $db->prepare('UPDATE user_settings SET v = ? WHERE person_id = ? AND k = ?'); + $prep->execute([json_encode(array_map('intval', $keep)), (int)$row['person_id'], 'group_order']); } } - /** - * 老库升级:把还没有分组的人按名册类型补一个合理默认。 - * 家庭成员 → 家人;外部联系人 → 认识。 - * 只补 NULL,用户自己改过的一律不动(幂等)。 - */ - public static function backfillMissing(\PDO $db): void + /** 按名字找分组 id(找不到返回 0)。默认在**公共**分组里找。 */ + public static function idByName(int $familyId, string $name, int $ownerId = self::PUBLIC_OWNER): int { - foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { - $fid = (int)$fam['id']; - // 家庭可能还没套用预置分组(ensure 的 seed 在表建好之后才跑) - $st = $db->prepare('SELECT COUNT(*) FROM person_groups WHERE family_id = ?'); - $st->execute([$fid]); - if ((int)$st->fetchColumn() === 0) { - self::seedDefaults($fid); - } - foreach ([['member', '家人'], ['contact', '认识']] as [$kind, $name]) { - $gid = self::idByName($fid, $name); - if ($gid === 0) { - continue; - } - $db->prepare( - 'UPDATE persons SET group_id = ? - WHERE family_id = ? AND kind = ? AND group_id IS NULL' - )->execute([$gid, $fid, $kind]); - } - } - } - - /** 按名字找分组 id(找不到返回 0) */ - public static function idByName(int $familyId, string $name): int - { - $st = Database::get()->prepare('SELECT id FROM person_groups WHERE family_id = ? AND name = ?'); - $st->execute([$familyId, $name]); + $st = Database::get()->prepare( + 'SELECT id FROM person_groups WHERE family_id = ? AND owner_id = ? AND name = ?' + ); + $st->execute([$familyId, max(0, $ownerId), $name]); return (int)($st->fetchColumn() ?: 0); } - /** 每个分组里有多少人(没分组的人记在键 0 上) */ - public static function counts(int $familyId, string $kind = 'member'): array + /** + * 某个分组下有多少人 —— **从我自己的名册里数**。 + * + * 这是 v5 的关键语义变化:老版本数的是"全家名册里 group_id = 它的有几个人", + * 那种数字对"我自己该怎么归类"没有指导意义(别人把人归到哪一组关我什么事)。 + * 现在数的是"我名册里归到这一组的有几个",用来给个人设置里的分组列表做提示。 + */ + public static function counts(int $familyId, int $ownerId, string $kind = 'member'): array { $st = Database::get()->prepare( - 'SELECT COALESCE(group_id, 0) AS g, COUNT(*) AS n - FROM persons WHERE family_id = ? AND kind = ? - GROUP BY COALESCE(group_id, 0)' + 'SELECT COALESCE(r.group_id, 0) AS g, COUNT(*) AS n + FROM person_roster r + JOIN persons p ON p.id = r.person_id + WHERE r.family_id = ? AND r.owner_id = ? AND p.kind = ? + GROUP BY COALESCE(r.group_id, 0)' ); - $st->execute([$familyId, $kind]); + $st->execute([$familyId, $ownerId, $kind]); $out = []; foreach ($st->fetchAll() as $r) { $out[(int)$r['g']] = (int)$r['n']; diff --git a/app/PersonRoster.php b/app/PersonRoster.php new file mode 100644 index 0000000..6123191 --- /dev/null +++ b/app/PersonRoster.php @@ -0,0 +1,311 @@ +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; + } +} diff --git a/app/Schema.php b/app/Schema.php index 26f101b..d12a237 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -26,8 +26,11 @@ final class Schema * 3 → 三个生日(birthday 升 DATETIME + id_card/lunar/leap)、slogan、nicknames 表 * 4 → person_groups(关系分组,全家统一但可自定义)、persons.group_id、 * user_settings(「我的」维度设置:分组顺序等) + * 5 → **名册改成「每人一份」**:person_roster(谁在我的名册里 / 归哪一组 / 排第几)、 + * person_groups.owner_id(0=管理员维护的公共分组,否则=某人的私自分组); + * 同时**删掉 persons.group_id**(分组从此是「我和他的关系」,挂在 roster 上) */ - public const VERSION = '4'; + public const VERSION = '5'; /** @return string[] */ public static function statements(): array @@ -60,14 +63,11 @@ CREATE TABLE IF NOT EXISTS `persons` ( `address` VARCHAR(200) DEFAULT NULL, `note` TEXT DEFAULT NULL 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, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_phone_hash` (`phone_hash`), - KEY `idx_family_kind` (`family_id`, `kind`, `sort_order`), - KEY `idx_person_group` (`group_id`), + KEY `idx_family_kind` (`family_id`, `kind`), CONSTRAINT `fk_persons_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='家庭名册' SQL, @@ -132,15 +132,35 @@ SQL, CREATE TABLE IF NOT EXISTS `person_groups` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `family_id` INT UNSIGNED NOT NULL, + `owner_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=全家的公共分组(管理员在系统设置里维护);否则=这个人的私自分组,只有他自己看得到', `name` VARCHAR(20) NOT NULL COMMENT '分组名,如「家人」「同事」', `position` INT NOT NULL DEFAULT 100 COMMENT '默认顺序(用户没自定义顺序时按它排)', `is_builtin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=安装时预置的分组', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 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`), 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' CREATE TABLE IF NOT EXISTS `user_settings` ( @@ -166,6 +186,37 @@ SQL, '不熟' => 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 { $db = Database::get(); + self::$stepErrors = []; foreach (self::statements() as $sql) { $db->exec($sql); } @@ -200,9 +252,11 @@ SQL, "ADD COLUMN `slogan` VARCHAR(200) DEFAULT NULL COMMENT '个人的一句话;没填就用家庭统一的那句' AFTER `note`"); // 老数据里 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 是全家共用的小名。 @@ -211,30 +265,65 @@ SQL, // 用户会觉得"以前填的小名一夜之间全没了"。搬的时候给每个账号都写一份, // 迁移前后大家看到的叫法完全一致(`Nickname::migrateLegacy` 里说明了原因)。 // 放在结构升级里、而不是"某次请求里顺手做",是为了升级完就已经搬好。 - foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { - \App\Nickname::migrateLegacy((int)$fam['id']); - } + self::tryStep('迁移旧称呼到 nicknames 表', static function () use ($db) { + foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { + \App\Nickname::migrateLegacy((int)$fam['id']); + } + }); - // ---- 关系分组(版本 4)---- - // persons 新增 group_id。这里**不加外键**:老库只能 ADD COLUMN,加不上约束, - // 新装的加了就会两边结构不一致;统一由 PersonGroup 保证不留悬空 id。 - self::addColumn($db, 'persons', 'group_id', - "ADD COLUMN `group_id` INT UNSIGNED DEFAULT NULL COMMENT '关系分组(人属于哪一组:家人/同事/朋友…),全家人共用一套分类' AFTER `is_placeholder`"); - // 每个家庭都要有一套预置分组(新装的由 install.php 建,老的在这里补) - foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { - \App\PersonGroup::seedDefaults((int)$fam['id']); + // ---- 关系分组:从「全家共用一套」改成「公共一套 + 每人可加自己的」(版本 5)---- + // person_groups 加 owner_id(0 = 公共)。新装的建表语句已是新结构,老库靠这里补。 + self::addColumn($db, 'person_groups', 'owner_id', + "ADD COLUMN `owner_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=全家的公共分组(管理员在系统设置里维护);否则=这个人的私自分组,只有他自己看得到' AFTER `family_id`"); + // 唯一键要从 (family_id,name) 换成 (family_id,owner_id,name): + // 否则「我的家人」和公共的「家人」会被判重名,没法各存一份。 + self::dropIndexIfExists($db, 'person_groups', 'uk_group_name'); + 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 { $st = $db->query( "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(); 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}`"); + } + } } \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css index 2304b83..08e0c00 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -166,7 +166,7 @@ a:hover { text-decoration: underline; } grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); } .person { - display: block; color: var(--ink); + display: block; color: var(--ink); position: relative; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; 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 .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-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-stage { - position: relative; margin: 0 auto; overflow: hidden; + position: relative; margin: 0 auto; overflow: auto; background: #F1E7D6; border-radius: var(--radius-s); touch-action: 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 { position: absolute; cursor: move; diff --git a/assets/js/app.js b/assets/js/app.js index fa1ab62..bf1c7e3 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -30,6 +30,13 @@ // 比例由**这个位置显示成什么形状**决定(头像 1:1、题图 16:9), // 由服务端通过 data-crop-ratio 告诉这里 —— 前端不再自己定一个数字。 // + // 缩放(用户明确要求「裁剪图片的时候,需要可以进行缩放」): + // 舞台是个**固定大小的视口**(baseW×baseH),图片按 zoom 放大到 baseW*zoom, + // 放不下就由舞台自己滚动。裁剪框的坐标一律相对**显示出来的图片**算, + // 最后除以 dispW/dispH 换成 0..1 —— 所以放大多少倍都不影响落库的那个矩形。 + // 框的最小边长固定 32 显示像素,于是放大之后能选中原图上更小的区域, + // 这正是"缩放"要解决的问题(合影里挑出某一个人的脸)。 + // // ⚠️ 这里只是「让用户挑区域」,真正裁切在服务端做(见 app/Upload.php): // 前端算出来的框只是**建议**,绕过前端也不会存进一张比例不对的图。 // 所以确认时写回的是**归一化矩形**(相对原图的 0..1),不是像素。 @@ -67,9 +74,15 @@ '