From 1c460a0e83860f277cbc4487f08172b5def86ba7 Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 30 Sep 2026 11:06:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=8F=AF=E8=A3=81=E5=89=AA?= =?UTF-8?q?=20=C2=B7=20=E5=90=8D=E5=86=8C=E6=8C=89=E5=85=B3=E7=B3=BB?= =?UTF-8?q?=E5=88=86=E7=BB=84=20=C2=B7=20=E4=B8=AA=E4=BA=BA=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E7=8B=AC=E7=AB=8B=20=C2=B7=20=E9=A1=B6=E6=A0=8F?= =?UTF-8?q?=E4=B8=8B=E6=8B=89=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 上传图片一律可裁剪,比例由图位决定 新增 app/ImageSlot.php 统一配置(头像 1:1/512×512;题图 16:9 只留注释, 将来启用只加一行)。前端只挑区域、写回归一化矩形 "x,y,w,h",真正裁剪在服务端 Upload::fitRegion():夹回图内 → 在框内居中收缩到目标比例(不外扩)→ 钉住 sx+sw ≤ w。 比例解析单独写 parseRatioAttr():parseFloat('16:9') 得 16,会把题图裁成细长条且不报错。 ② 名册按关系分组,顺序每人可自定 分类全家共用(person_groups,另一个人可以自己加分类);「自己的」只体现在顺序上 (user_settings.group_order,每人一份)。persons.group_id 故意不加外键(老库只能 ADD COLUMN),完整性由 PersonGroup 保证:删分类把人置回未分类,人不会从名册消失。 ③ 设置分两级:系统设置 / 个人设置 个人设置拆成 templates/settings/me.php + _profile.php + _nicknames.php, 取代 templates/people/me.php(旧路由与旧表单仍兼容)。 ④ 右上角「姓名/头像 + 下拉菜单」 原来是两个裸链接,窄屏会被 CSS 藏掉导致改密码/退出不可达;改成
零 JS 菜单 (app.js 只补「点外面 / Esc」)。菜单项:个人设置 / 系统设置(管理员)/ 修改密码 / 退出登录。 Schema 版本 3 → 4;安装与老库升级共用 PersonGroup::backfillMissing() 一条回填路径。 验证:php -l 全量;e2e 202/0(新增「关系分组」与「图片裁剪」两节);老库升级 49/0; 桌面几何 108/0;移动端几何 192/0(360/375/390/414);交互 76/0; 反向验证 31/31(逐条把修复改回旧写法,指定的目标断言必须变红); 部署后本地↔线上 72 个文件 sha256 逐字节一致。 --- app/ImageSlot.php | 82 +++++ app/Person.php | 22 +- app/PersonGroup.php | 288 ++++++++++++++++++ app/Schema.php | 54 +++- app/Upload.php | 198 ++++++++++-- app/UserSetting.php | 41 +++ assets/css/app.css | 171 ++++++++++- assets/js/app.js | 245 ++++++++++++++- index.php | 214 +++++++++++-- install.php | 8 + templates/home.php | 2 +- templates/layout.php | 32 +- templates/people/form.php | 35 ++- templates/people/index.php | 180 ++++++++--- templates/people/show.php | 2 +- templates/settings/_nicknames.php | 45 +++ .../{people/me.php => settings/_profile.php} | 80 ++--- templates/settings/me.php | 120 ++++++++ 18 files changed, 1637 insertions(+), 182 deletions(-) create mode 100644 app/ImageSlot.php create mode 100644 app/PersonGroup.php create mode 100644 app/UserSetting.php create mode 100644 templates/settings/_nicknames.php rename templates/{people/me.php => settings/_profile.php} (69%) create mode 100644 templates/settings/me.php diff --git a/app/ImageSlot.php b/app/ImageSlot.php new file mode 100644 index 0000000..1c1296c --- /dev/null +++ b/app/ImageSlot.php @@ -0,0 +1,82 @@ + ['label' => '题图', 'ratio' => '16:9', 'w' => 1920, 'h' => 1080, + * 'dir' => 'covers', 'hint' => '宽屏横幅,显示在详情页顶部'], + */ + +declare(strict_types=1); + +namespace App; + +final class ImageSlot +{ + /** 上传体积上限(前端也会提示,但以这里为准) */ + public const MAX_BYTES = 5 * 1024 * 1024; + + /** + * 位置 → 比例与输出尺寸。 + * + * `dir` 是**存储前缀**:本地就是 uploads//,云端就是桶里的 //。 + * `w`/`h` 是**输出**尺寸(不是要求上传者提供的尺寸)—— + * 裁剪之后统一缩到这个大小,所以原图多大都不影响页面上的加载量。 + * + * ratio 写成字符串是为了让人一眼看懂,也方便直接拿去当 CSS 的 aspect-ratio。 + */ + public const SLOTS = [ + 'avatar' => [ + 'label' => '头像', + 'ratio' => '1:1', + 'w' => 512, + 'h' => 512, + 'dir' => 'avatars', + 'hint' => '正方形。页面上显示成圆形或圆角方形,所以按 1:1 裁。', + ], + // 题图按 16:9 —— 宽屏横幅。现在还没有位置用它,需要时把它启用即可: + // 'cover' => ['label' => '题图', 'ratio' => '16:9', 'w' => 1920, 'h' => 1080, + // 'dir' => 'covers', 'hint' => '宽屏横幅,显示在详情页顶部。'], + ]; + + public static function exists(string $key): bool + { + return isset(self::SLOTS[$key]); + } + + /** @return array|null */ + public static function get(string $key): ?array + { + return self::SLOTS[$key] ?? null; + } + + /** 宽高比(宽 / 高)。'1:1' → 1.0,'16:9' → 1.777… */ + public static function ratio(array $slot): float + { + [$a, $b] = self::parseRatio((string)($slot['ratio'] ?? '1:1')); + return $b > 0 ? $a / $b : 1.0; + } + + /** 给前端用的 CSS aspect-ratio 写法:'1 / 1'、'16 / 9' */ + public static function ratioCss(array $slot): string + { + [$a, $b] = self::parseRatio((string)($slot['ratio'] ?? '1:1')); + return $a . ' / ' . $b; + } + + /** @return array{0:int,1:int} */ + private static function parseRatio(string $ratio): array + { + if (preg_match('/^\s*(\d+)\s*[:/]\s*(\d+)\s*$/', $ratio, $m)) { + $a = max(1, (int)$m[1]); + $b = max(1, (int)$m[2]); + return [$a, $b]; + } + return [1, 1]; + } +} diff --git a/app/Person.php b/app/Person.php index f4ec7dc..f59e2b1 100644 --- a/app/Person.php +++ b/app/Person.php @@ -91,8 +91,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, sort_order) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'; + is_placeholder, group_id, sort_order) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'; $st = Database::get()->prepare($sql); $st->execute([ $familyId, @@ -113,6 +113,7 @@ 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(); @@ -126,7 +127,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 = ?, sort_order = ? + email = ?, address = ?, note = ?, is_placeholder = ?, group_id = ?, sort_order = ? WHERE id = ?'; Database::get()->prepare($sql)->execute([ $d['name'], @@ -145,11 +146,26 @@ 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]); + } + /** * 自助改自己的资料(个人信息页用)。 * diff --git a/app/PersonGroup.php b/app/PersonGroup.php new file mode 100644 index 0000000..2513cbe --- /dev/null +++ b/app/PersonGroup.php @@ -0,0 +1,288 @@ +prepare( + 'INSERT IGNORE INTO person_groups (family_id, name, position, is_builtin) VALUES (?, ?, ?, 1)' + ); + foreach (Schema::DEFAULT_GROUPS as $name => $pos) { + $st->execute([$familyId, $name, $pos]); + } + } + + /** 全部分组,id => 行(按默认顺序) */ + public static function byId(int $familyId): array + { + $st = Database::get()->prepare( + 'SELECT * FROM person_groups WHERE family_id = ? ORDER BY position ASC, id ASC' + ); + $st->execute([$familyId]); + $out = []; + foreach ($st->fetchAll() as $r) { + $out[(int)$r['id']] = $r; + } + return $out; + } + + /** + * 按**当前用户自定义的顺序**返回分组(用户没自定义过就按默认位置)。 + * + * 自定义顺序里没提到的分组(用户排序之后才新建的)一律追加到最后 —— + * 这样「新建一个分组」不会因为它不在旧顺序里就消失不见。 + */ + public static function orderFor(int $familyId, int $viewerId = 0): array + { + $rest = self::byId($familyId); + if (!$rest) { + return []; + } + if ($viewerId > 0) { + $raw = (string)UserSetting::get($viewerId, 'group_order', ''); + $saved = $raw === '' ? null : json_decode($raw, true); + if (is_array($saved) && $saved) { + $ordered = []; + foreach ($saved as $id) { + $id = (int)$id; + if (isset($rest[$id])) { + $ordered[$id] = $rest[$id]; + unset($rest[$id]); + } + } + foreach ($rest as $id => $g) { + $ordered[$id] = $g; + } + return array_values($ordered); + } + } + return array_values($rest); + } + + /** 保存「我」的分组顺序(只影响我自己) */ + public static function saveOrder(int $familyId, int $viewerId, array $ids): void + { + $valid = self::byId($familyId); + $clean = []; + foreach ($ids as $id) { + $id = (int)$id; + if (isset($valid[$id]) && !in_array($id, $clean, true)) { + $clean[] = $id; + } + } + // 没提到的补齐到末尾 —— 保存完整顺序,读回来才不会因为"缺项"而错位 + foreach (array_keys($valid) as $id) { + if (!in_array($id, $clean, true)) { + $clean[] = $id; + } + } + UserSetting::set($viewerId, 'group_order', json_encode($clean, JSON_UNESCAPED_UNICODE)); + } + + /** 分组名(找不到就返回空串,调用方自己决定回退文案) */ + public static function name(int $familyId, ?int $id): string + { + if (!$id) { + return ''; + } + $st = Database::get()->prepare('SELECT name FROM person_groups WHERE family_id = ? AND id = ?'); + $st->execute([$familyId, $id]); + return (string)($st->fetchColumn() ?: ''); + } + + /** + * 新增一个分组。 + * @return array{ok:bool,id?:int,error?:string} + */ + public static function add(int $familyId, string $name): array + { + $name = trim($name); + $bad = self::validateName($name); + if ($bad !== '') { + return ['ok' => false, 'error' => $bad]; + } + $st = Database::get()->prepare( + 'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND name = ?' + ); + $st->execute([$familyId, $name]); + if ((int)$st->fetchColumn() > 0) { + return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。']; + } + // ⚠️ 别写成 `$max = (int)$db->prepare(...)->execute([...])`: + // PDOStatement::execute() 返回 bool,强转永远是 1 —— 位置会算错。 + $st2 = Database::get()->prepare('SELECT COALESCE(MAX(position), 0) FROM person_groups WHERE family_id = ?'); + $st2->execute([$familyId]); + $max = (int)$st2->fetchColumn(); + Database::get()->prepare( + 'INSERT INTO person_groups (family_id, name, position, is_builtin) VALUES (?, ?, ?, 0)' + )->execute([$familyId, $name, max($max + 10, 80)]); + return ['ok' => true, 'id' => (int)Database::get()->lastInsertId()]; + } + + /** @return array{ok:bool,error?:string} */ + public static function rename(int $familyId, int $id, string $name): array + { + $name = trim($name); + $bad = self::validateName($name); + if ($bad !== '') { + return ['ok' => false, 'error' => $bad]; + } + $st = Database::get()->prepare( + 'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND name = ? AND id <> ?' + ); + $st->execute([$familyId, $name, $id]); + if ((int)$st->fetchColumn() > 0) { + return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。']; + } + $st = Database::get()->prepare('UPDATE person_groups SET name = ? WHERE family_id = ? AND id = ?'); + $st->execute([$name, $familyId, $id]); + if ($st->rowCount() === 0) { + return ['ok' => false, 'error' => '找不到这个分组。']; + } + return ['ok' => true]; + } + + /** + * 删除一个分组。挂在这个分组下的人**不会消失**,只是变成「未分类」—— + * 名册上会多出一个「未分类」组提醒去归类。 + * 最后一个分组不给删:全删光就没有任何分类可用了,那是死路。 + * + * @return array{ok:bool,error?:string,moved?:int} + */ + public static function delete(int $familyId, int $id): array + { + $all = self::byId($familyId); + if (!isset($all[$id])) { + return ['ok' => false, 'error' => '找不到这个分组。']; + } + if (count($all) <= 1) { + return ['ok' => false, 'error' => '至少要留一个分组 —— 全删光就没法给成员归类了。']; + } + $db = Database::get(); + $st = $db->prepare('SELECT COUNT(*) FROM persons WHERE family_id = ? AND group_id = ?'); + $st->execute([$familyId, $id]); + $moved = (int)$st->fetchColumn(); + + $db->prepare('UPDATE persons SET group_id = NULL WHERE family_id = ? AND group_id = ?') + ->execute([$familyId, $id]); + $db->prepare('DELETE FROM person_groups WHERE family_id = ? AND id = ?') + ->execute([$familyId, $id]); + + // 各人自定义顺序里可能留着这个 id,清掉免得越积越脏 + self::pruneOrder($familyId, $id); + return ['ok' => true, 'moved' => $moved]; + } + + /** 分组名规则:1–20 字、不许只剩空白 */ + private static function validateName(string $name): string + { + if ($name === '') { + return '分组名不能为空。'; + } + if (mb_strlen($name, 'UTF-8') > 20) { + return '分组名请控制在 20 字以内。'; + } + return ''; + } + + /** 从所有人的自定义顺序里剔除某个分组 id(删组之后调用) */ + private static function pruneOrder(int $familyId, int $removedId): void + { + $db = Database::get(); + $st = $db->prepare( + 'SELECT us.person_id, us.v FROM user_settings us + JOIN persons p ON p.id = us.person_id + WHERE p.family_id = ? AND us.k = ?' + ); + $st->execute([$familyId, 'group_order']); + foreach ($st->fetchAll() as $row) { + $ids = json_decode((string)$row['v'], true); + if (!is_array($ids)) { + continue; + } + $keep = array_values(array_filter($ids, fn($x) => (int)$x !== $removedId)); + $db->prepare('UPDATE user_settings SET v = ? WHERE person_id = ? AND k = ?') + ->execute([json_encode(array_map('intval', $keep)), (int)$row['person_id'], 'group_order']); + } + } + + /** + * 老库升级:把还没有分组的人按名册类型补一个合理默认。 + * 家庭成员 → 家人;外部联系人 → 认识。 + * 只补 NULL,用户自己改过的一律不动(幂等)。 + */ + public static function backfillMissing(\PDO $db): void + { + foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { + $fid = (int)$fam['id']; + // 家庭可能还没套用预置分组(ensure 的 seed 在表建好之后才跑) + $st = $db->prepare('SELECT COUNT(*) FROM person_groups WHERE family_id = ?'); + $st->execute([$fid]); + if ((int)$st->fetchColumn() === 0) { + self::seedDefaults($fid); + } + foreach ([['member', '家人'], ['contact', '认识']] as [$kind, $name]) { + $gid = self::idByName($fid, $name); + if ($gid === 0) { + continue; + } + $db->prepare( + 'UPDATE persons SET group_id = ? + WHERE family_id = ? AND kind = ? AND group_id IS NULL' + )->execute([$gid, $fid, $kind]); + } + } + } + + /** 按名字找分组 id(找不到返回 0) */ + public static function idByName(int $familyId, string $name): int + { + $st = Database::get()->prepare('SELECT id FROM person_groups WHERE family_id = ? AND name = ?'); + $st->execute([$familyId, $name]); + return (int)($st->fetchColumn() ?: 0); + } + + /** 每个分组里有多少人(没分组的人记在键 0 上) */ + public static function counts(int $familyId, string $kind = 'member'): array + { + $st = Database::get()->prepare( + 'SELECT COALESCE(group_id, 0) AS g, COUNT(*) AS n + FROM persons WHERE family_id = ? AND kind = ? + GROUP BY COALESCE(group_id, 0)' + ); + $st->execute([$familyId, $kind]); + $out = []; + foreach ($st->fetchAll() as $r) { + $out[(int)$r['g']] = (int)$r['n']; + } + return $out; + } +} diff --git a/app/Schema.php b/app/Schema.php index 6b645e5..26f101b 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -24,8 +24,10 @@ final class Schema * 1 → 初始(families/persons/accounts/settings/relations) * 2 → birth_rank、is_placeholder、accounts.role 加 guest * 3 → 三个生日(birthday 升 DATETIME + id_card/lunar/leap)、slogan、nicknames 表 + * 4 → person_groups(关系分组,全家统一但可自定义)、persons.group_id、 + * user_settings(「我的」维度设置:分组顺序等) */ - public const VERSION = '3'; + public const VERSION = '4'; /** @return string[] */ public static function statements(): array @@ -58,12 +60,14 @@ 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`), 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, @@ -123,10 +127,45 @@ CREATE TABLE IF NOT EXISTS `nicknames` ( CONSTRAINT `fk_nick_viewer` FOREIGN KEY (`viewer_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_nick_target` FOREIGN KEY (`target_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='称呼是视角属性:每个人各自决定怎么称呼别人,不挂在被称呼者身上' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `person_groups` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `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`), + 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='关系分组:全家人共用一套(一个人只属于一个分组),但每个人可以各自决定分组的先后' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `user_settings` ( + `person_id` INT UNSIGNED NOT NULL COMMENT '这条设置属于谁(登录者本人)', + `k` VARCHAR(64) NOT NULL, + `v` TEXT DEFAULT NULL, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`person_id`, `k`), + CONSTRAINT `fk_uset_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='「我自己的」设置:只影响我自己看到的样子' SQL, ]; } + /** 安装时预置的关系分组(用户可自行增删改名,这里只是初始值) */ + public const DEFAULT_GROUPS = [ + '家人' => 10, + '亲戚' => 20, + '同事' => 30, + '朋友' => 40, + '合作伙伴' => 50, + '认识' => 60, + '不熟' => 70, + ]; + /** * 幂等结构升级:建表 + 补齐后加的列/枚举值。 * @@ -175,6 +214,19 @@ SQL, 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']); + } + // 老库的人还没有分组 → 按名册类型补:家庭成员=家人、外部联系人=认识。 + // 只补空的,用户自己改过的一律不动。 + \App\PersonGroup::backfillMissing($db); } /** 老库已有阳历生日但没有农历生日 → 补算一次。用「为空」作条件,天然幂等。 */ diff --git a/app/Upload.php b/app/Upload.php index 6688ad2..034b7e8 100644 --- a/app/Upload.php +++ b/app/Upload.php @@ -1,13 +1,20 @@ false, 'error' => '上传位置配置不正确(' . $slotKey . ')']; + } + + // 「表单里压根没有这个字段」= 用户没打算换图,不是错误。 // ⚠️ 早先这里一律报「上传参数不对」,于是任何**不带 enctype 多部分**的 // 调用(或没有该字段的表单)都会被判成校验失败,整条新增/编辑被挡下, // 表现却是「表单提交后又回到表单、什么都没说」—— 很难查。 @@ -41,13 +56,13 @@ final class Upload return ['ok' => false, 'error' => '']; case UPLOAD_ERR_INI_SIZE: case UPLOAD_ERR_FORM_SIZE: - return ['ok' => false, 'error' => '图片太大了(上限 5MB)']; + return ['ok' => false, 'error' => '图片太大了(上限 ' . self::maxMbWord() . ')']; default: return ['ok' => false, 'error' => '上传失败(错误码 ' . (int)$file['error'] . ')']; } - if ($file['size'] > self::MAX_BYTES) { - return ['ok' => false, 'error' => '图片太大了(上限 5MB)']; + if ((int)$file['size'] > ImageSlot::MAX_BYTES) { + return ['ok' => false, 'error' => '图片太大了(上限 ' . self::maxMbWord() . ')']; } $finfo = new \finfo(FILEINFO_MIME_TYPE); @@ -58,26 +73,45 @@ final class Upload return ['ok' => false, 'error' => '只支持 JPG / PNG / GIF / WebP 图片']; } - $src = $allow[$mime]($file['tmp_name']); + // 先用 getimagesize 拿到尺寸 —— 像素数太大就直接拒,避免 imagecreatefrom* 把内存吃光 + $info = @getimagesize($file['tmp_name']); + if (!$info || $info[0] < 1 || $info[1] < 1) { + return ['ok' => false, 'error' => '图片读不出来']; + } + if ($info[0] * $info[1] > 40_000_000) { + return ['ok' => false, 'error' => '图片像素太多(超过 4000 万),请先压缩再传']; + } + + $src = @$allow[$mime]($file['tmp_name']); if (!$src) { return ['ok' => false, 'error' => '图片读不出来']; } - $w = imagesx($src); $h = imagesy($src); - $side = min($w, $h); - $sx = (int)(($w - $side) / 2); - $sy = (int)(($h - $side) / 2); - $canvas = imagecreatetruecolor(self::SIZE, self::SIZE); + // 先按目标比例、在「用户挑的区域」里定位 —— 区域非法时退回整图居中 + $region = self::readCrop($cropRaw, $w, $h); + [$sx, $sy, $sw, $sh] = self::fitRegion($region ?? [0.0, 0.0, 1.0, 1.0], $w, $h, ImageSlot::ratio($slot)); + // 「框小到没有意义」要明确拒绝(放大之后就是一坨马赛克)。 + // ⚠️ 只在**用户真的给了裁剪框**时判:源图本身很小(比如 8×8 的老头像、 + // 或者是张一像素级的测试图)时,居中裁出来的区域自然也小 —— + // 那种情况必须照常接受,否则一张本来能用的图会被"太小"挡在外面。 + if ($region !== null && ($sw < 16 || $sh < 16)) { + imagedestroy($src); + return ['ok' => false, 'error' => '裁剪区域太小了,请把框放大一点']; + } + + $outW = (int)$slot['w']; + $outH = (int)$slot['h']; + $canvas = imagecreatetruecolor($outW, $outH); // PNG/GIF 的透明区转成纸白,不然头像会是黑底 $bg = imagecolorallocate($canvas, 253, 251, 245); - imagefilledrectangle($canvas, 0, 0, self::SIZE, self::SIZE, $bg); - imagecopyresampled($canvas, $src, 0, 0, $sx, $sy, self::SIZE, self::SIZE, $side, $side); + imagefilledrectangle($canvas, 0, 0, $outW, $outH, $bg); + imagecopyresampled($canvas, $src, 0, 0, $sx, $sy, $outW, $outH, $sw, $sh); imagedestroy($src); // 先在临时目录生成,再整体交给 Storage —— 这样本地与 OSS 是同一条路径 - $tmpOut = tempnam(sys_get_temp_dir(), 'fbav'); + $tmpOut = tempnam(sys_get_temp_dir(), 'fbimg'); if ($tmpOut === false || !imagejpeg($canvas, $tmpOut, 88)) { imagedestroy($canvas); @unlink((string)$tmpOut); @@ -85,21 +119,131 @@ final class Upload } imagedestroy($canvas); - $key = Storage::AVATAR_DIR . '/' . bin2hex(random_bytes(12)) . '.jpg'; + $key = (string)$slot['dir'] . '/' . bin2hex(random_bytes(12)) . '.jpg'; $res = Storage::putFile($familyId, $tmpOut, $key, 'image/jpeg'); @unlink($tmpOut); if (empty($res['ok'])) { return ['ok' => false, 'error' => '保存失败:' . (string)($res['error'] ?? '')]; } - $out = ['ok' => true, 'file' => $key]; + $out = ['ok' => true, 'file' => $key, 'slot' => $slotKey, + 'crop' => ['x' => $sx, 'y' => $sy, 'w' => $sw, 'h' => $sh]]; if (!empty($res['note'])) { $out['note'] = (string)$res['note']; } return $out; } - /** 头像访问地址(无头像时回退内置剪影,保证永远不出现破图) */ + /** + * 头像(= avatar 位置)。保留这个入口,调用方不必关心槽位名。 + * @return array{ok:bool,file?:string,error?:string,note?:string} + */ + public static function avatar(int $familyId, array $file, string $cropRaw = ''): array + { + return self::image($familyId, $file, 'avatar', $cropRaw); + } + + /** + * 解析归一化裁剪矩形 "x,y,w,h"(各 0..1,相对原图)。 + * 任何一项非法(不是数字、越界、宽高为 0)→ 返回 null,调用方退回居中裁切。 + * + * ⚠️ 只做**解析与粗筛**,不做"像素级贴合":真正的定位在 fitRegion() 里, + * 它会再把区域夹回图内、并调成目标比例。这里如果直接把非法值夹成合法值, + * 就会出现"用户拖了个空框却莫名得到一张图"的怪事 —— 宁可退回居中。 + */ + private static function readCrop(string $raw, int $w, int $h): ?array + { + $raw = trim($raw); + if ($raw === '') { + return null; + } + $parts = explode(',', $raw); + if (count($parts) !== 4) { + return null; + } + $vals = []; + foreach ($parts as $p) { + $p = trim($p); + if (!is_numeric($p)) { + return null; + } + $vals[] = (float)$p; + } + [$x, $y, $cw, $ch] = $vals; + if ($cw <= 0 || $ch <= 0) { + return null; + } + // 允许 -0.001 这种浮点毛刺,但明显越界的直接判非法 + if ($x < -0.001 || $y < -0.001 || $x > 1.001 || $y > 1.001 + || $cw > 1.001 || $ch > 1.001) { + return null; + } + return [max(0.0, $x), max(0.0, $y), min(1.0, $cw), min(1.0, $ch)]; + } + + /** + * 把归一化区域变成**像素级、且比例等于 $ratio** 的裁切框。 + * + * 三步,缺一不可: + * ① 夹回图内(区域右/下边可能超出); + * ② 调成目标比例(前端要是被绕过、或报了稍有不同的比例,这里纠正); + * 做法是**在给定区域内居中收缩**,绝不外扩 —— 用户挑的地方一定还在框里; + * ③ 取整并对齐边界,保证 sx+sw ≤ w。 + * + * @param array{0:float,1:float,2:float,3:float} $region + * @return array{0:int,1:int,2:int,3:int} + */ + private static function fitRegion(array $region, int $w, int $h, float $ratio): array + { + [$nx, $ny, $nw, $nh] = $region; + + // ① 夹回图内 + $nx = min(max(0.0, $nx), 1.0); + $ny = min(max(0.0, $ny), 1.0); + $nw = min($nw, 1.0 - $nx); + $nh = min($nh, 1.0 - $ny); + if ($nw <= 0 || $nh <= 0) { + $nx = $ny = 0.0; + $nw = $nh = 1.0; + } + + $sx = $nx * $w; + $sy = $ny * $h; + $sw = $nw * $w; + $sh = $nh * $h; + + // ② 在区域内居中收缩到目标比例 + if ($ratio > 0 && $sh > 0) { + $cur = $sw / $sh; + if ($cur > $ratio + 1e-6) { // 太宽 → 收窄 + $newW = $sh * $ratio; + $sx += ($sw - $newW) / 2; + $sw = $newW; + } elseif ($cur < $ratio - 1e-6) { // 太高 → 收矮 + $newH = $sw / $ratio; + $sy += ($sh - $newH) / 2; + $sh = $newH; + } + } + + // ③ 取整 + 保证不越界 + $ix = (int)round($sx); + $iy = (int)round($sy); + $iw = (int)round($sw); + $ih = (int)round($sh); + $ix = max(0, min($ix, $w - 1)); + $iy = max(0, min($iy, $h - 1)); + $iw = max(1, min($iw, $w - $ix)); + $ih = max(1, min($ih, $h - $iy)); + return [$ix, $iy, $iw, $ih]; + } + + private static function maxMbWord(): string + { + return (int)round(ImageSlot::MAX_BYTES / 1024 / 1024) . 'MB'; + } + + /** 图片访问地址(无图时回退内置剪影,保证永远不出现破图) */ public static function avatarUrl(int $familyId, ?string $rel): string { $rel = trim((string)$rel); @@ -121,4 +265,4 @@ final class Upload { return Storage::effectiveDriver($familyId); } -} \ No newline at end of file +} diff --git a/app/UserSetting.php b/app/UserSetting.php new file mode 100644 index 0000000..cee989d --- /dev/null +++ b/app/UserSetting.php @@ -0,0 +1,41 @@ +prepare('SELECT v FROM user_settings WHERE person_id = ? AND k = ?'); + $st->execute([$personId, $key]); + $v = $st->fetchColumn(); + return $v === false ? $default : $v; + } + + public static function set(int $personId, string $key, string $value): void + { + if ($personId <= 0) { + return; + } + Database::get()->prepare( + 'INSERT INTO user_settings (person_id, k, v) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE v = VALUES(v)' + )->execute([$personId, $key, $value]); + } +} diff --git a/assets/css/app.css b/assets/css/app.css index f5b4cd7..2304b83 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -64,12 +64,42 @@ a:hover { text-decoration: underline; } .topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; } .who { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 14px; } -a.who:hover { text-decoration: none; } -/* 头像/名字是个「编辑我自己」的入口 —— hover 时给点反馈,让人知道它可点 */ -a.who:hover .who-name { color: var(--brand); text-decoration: underline; } -a.who:hover img { box-shadow: 0 0 0 2px var(--brand-2); } +/* 姓名 + 头像是个下拉菜单的「把手」——hover 时给点反馈,让人知道它可点 */ +.who-menu { position: relative; } +.who-menu > summary { list-style: none; cursor: pointer; padding: 4px 6px; border-radius: 999px; } +.who-menu > summary::-webkit-details-marker { display: none; } +.who-menu > summary:hover { background: var(--line-2); } +.who-menu > summary:hover .who-name { color: var(--brand); } +.who-menu[open] > summary { background: var(--line-2); } +.who-menu[open] > summary .caret { transform: rotate(180deg); } .who img { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; } .who img { transition: box-shadow .15s ease; } +.who-menu > summary:hover img { box-shadow: 0 0 0 2px var(--brand-2); } +/* 那个小三角:没有它,用户不知道"名字能点开" */ +.caret { + width: 0; height: 0; margin-left: -2px; + border-left: 4px solid transparent; border-right: 4px solid transparent; + border-top: 5px solid var(--faint); + transition: transform .15s ease; +} +.menu-list { + position: absolute; right: 0; top: calc(100% + 8px); z-index: 30; + min-width: 148px; padding: 6px; + background: var(--surface); border: 1px solid var(--line); + border-radius: var(--radius-s); box-shadow: var(--shadow-l); + display: flex; flex-direction: column; +} +.menu-list a { + padding: 8px 12px; border-radius: 8px; color: var(--ink); + font-size: 14px; white-space: nowrap; +} +.menu-list a:hover { background: var(--line-2); text-decoration: none; color: var(--brand); } +/* 退出放在最下面并隔一条线:它是"离开",和上面几个"改点什么"不是一类动作 */ +.menu-list .menu-exit { + margin-top: 4px; padding-top: 9px; border-top: 1px solid var(--line-2); + border-radius: 0 0 8px 8px; color: var(--muted); +} +.menu-list .menu-exit:hover { color: var(--brand); } .link-quiet { color: var(--muted); font-size: 14px; } .link-quiet:hover { color: var(--brand); } @@ -518,3 +548,136 @@ textarea.input { min-height: 76px; resize: vertical; } .oss-diag-table th:nth-child(3), .oss-diag-table td:nth-child(3) { display: none; } } + +/* ============================================================ + 以下都是 2026-09-30 那一批反馈加的东西 + ============================================================ */ + +/* 只给读屏用的标签:视觉上不占地,但无障碍名还在(别用 display:none,那会一起藏掉) */ +.sr-only { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} +/* 区块之间的小间隔(比写 style="margin-top:20px" 好读) */ +.stack-gap { height: 20px; } + +/* ---------------- 名册:按关系分组分栏 ---------------- */ +.group-block { margin-top: 24px; } +.group-head { + display: flex; align-items: baseline; gap: 8px; + margin: 0 0 10px; padding-bottom: 8px; + border-bottom: 1px solid var(--line); + font-size: 15px; font-weight: 600; +} +.group-head .group-title { color: var(--ink); } +/* 分组名前面那道砖红短线,让分栏在长页面上更容易一眼定位 */ +.group-head .group-title::before { + content: ''; display: inline-block; vertical-align: middle; + width: 3px; height: 13px; margin-right: 8px; border-radius: 2px; + background: var(--brand); +} +.group-head .group-n { margin-left: auto; font-weight: 400; font-size: 12px; color: var(--faint); } + +/* 卡片改成 div > a + details:表单不能套在链接里(非法 HTML,浏览器会把 DOM 拆开) */ +.person-main { display: block; color: var(--ink); } +.person-main:hover { text-decoration: none; } +.person-more { + border-top: 1px solid var(--line-2); background: #FCFAF6; +} +.person-more > summary { + list-style: none; cursor: pointer; padding: 7px 14px; + font-size: 12px; color: var(--muted); +} +.person-more > summary::-webkit-details-marker { display: none; } +.person-more > summary::after { content: ' ▾'; } +.person-more[open] > summary::after { content: ' ▴'; } +.person-more > summary:hover { color: var(--brand); } +.person-move { display: flex; gap: 6px; padding: 0 14px 12px; } +.person-move .select { flex: 1 1 auto; min-width: 0; padding: 6px 8px; font-size: 13px; } +.person-move .btn { flex: 0 0 auto; } + +/* ---------------- 个人设置:分组顺序 ---------------- */ +.order-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.order-row { + display: flex; align-items: center; gap: 10px; + padding: 8px 12px; border-radius: var(--radius-s); + background: #FAF7F1; border: 1px solid var(--line-2); +} +.order-no { + flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; + background: var(--brand); color: #fff; + font-size: 12px; line-height: 22px; text-align: center; +} +.order-name { flex: 1 1 auto; min-width: 0; font-size: 14px; } +.order-count { flex: 0 0 auto; font-size: 12px; color: var(--faint); } +.order-move { flex: 0 0 auto; display: flex; gap: 4px; } +.order-move .btn { padding: 2px 8px; line-height: 1.4; } +.order-move .btn[disabled] { opacity: .35; cursor: default; } + +/* ---------------- 个人设置:关系分类管理 ---------------- */ +.inline-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.inline-form .input { flex: 1 1 200px; max-width: 320px; } +.group-manage { list-style: none; margin: 16px 0 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.group-manage-row { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding: 8px 12px; border-radius: var(--radius-s); + background: #FAF7F1; border: 1px solid var(--line-2); +} +.group-rename { display: flex; gap: 8px; align-items: center; flex: 1 1 240px; min-width: 0; } +.group-rename .input { flex: 1 1 auto; min-width: 0; max-width: 220px; padding: 6px 10px; font-size: 13px; } +.group-manage-row .group-count { flex: 0 0 auto; } +.del-wrap > summary { list-style: none; cursor: pointer; } +.del-wrap[open] { flex: 1 1 100%; } +.del-confirm { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 8px 0 2px; } +.btn.danger { color: var(--red); border-color: rgba(192, 83, 63, .35); } +.btn.danger:hover { background: rgba(192, 83, 63, .08); } + +/* ---------------- 图片裁剪浮层 ---------------- */ +/* 覆盖层默认不占空间;打开时铺满整屏,把底下的页面锁住(裁剪时不该能点到别处) */ +.crop-overlay { display: none; } +.crop-overlay.is-open { + display: flex; position: fixed; inset: 0; z-index: 100; + align-items: center; justify-content: center; + padding: 20px; background: rgba(44, 35, 29, .55); +} +.crop-panel { + background: var(--surface); border-radius: var(--radius); + box-shadow: var(--shadow-l); padding: 16px; + max-width: calc(100vw - 24px); max-height: calc(100vh - 24px); overflow: auto; +} +.crop-head { + display: flex; align-items: baseline; gap: 8px; + font-size: 15px; font-weight: 600; margin-bottom: 12px; +} +.crop-head .crop-shape { font-size: 12px; font-weight: 400; color: var(--muted); } +.crop-stage { + position: relative; margin: 0 auto; overflow: hidden; + background: #F1E7D6; border-radius: var(--radius-s); + touch-action: none; /* 手机上拖框时别把页面一起滚走 */ + user-select: none; +} +.crop-img { display: block; width: 100%; height: 100%; } +/* 框外的部分压暗 —— 这是"你只留下框里这块"最直观的表达 */ +.crop-box { + position: absolute; cursor: move; + border: 2px solid #fff; + box-shadow: 0 0 0 9999px rgba(44, 35, 29, .5); +} +.crop-box::before { + content: ''; position: absolute; inset: 0; + border: 1px dashed rgba(255, 255, 255, .75); pointer-events: none; +} +.crop-handle { + position: absolute; right: -8px; bottom: -8px; + width: 20px; height: 20px; border-radius: 50%; + background: #fff; border: 2px solid var(--brand); + cursor: nwse-resize; +} +.crop-tip { color: var(--muted); font-size: 12px; margin: 12px 0 0; } +.crop-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 14px; } + +@media (max-width: 720px) { + .order-move .btn { padding: 4px 10px; } + .group-manage-row { align-items: flex-start; } + .crop-panel { padding: 12px; } +} diff --git a/assets/js/app.js b/assets/js/app.js index 83c9dba..fa1ab62 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -26,9 +26,240 @@ for (var i = 0; i < pickers.length; i++) { hook(pickers[i]); } })(); - // 头像选好后即时预览,别等上传完才知道选错了 + // ===== 图片裁剪 ===== + // 比例由**这个位置显示成什么形状**决定(头像 1:1、题图 16:9), + // 由服务端通过 data-crop-ratio 告诉这里 —— 前端不再自己定一个数字。 + // + // ⚠️ 这里只是「让用户挑区域」,真正裁切在服务端做(见 app/Upload.php): + // 前端算出来的框只是**建议**,绕过前端也不会存进一张比例不对的图。 + // 所以确认时写回的是**归一化矩形**(相对原图的 0..1),不是像素。 + (function () { + var inputs = Array.prototype.slice.call( + document.querySelectorAll('input[type="file"][data-crop-ratio]')); + if (!inputs.length) { return; } + + var overlay = null, stage = null, imgEl = null, boxEl = null; + var st = null; // 当前编辑状态 + + /** + * 解析 data-crop-ratio。 + * + * ⚠️ 不能直接 parseFloat:'16:9' 会被读成 **16**(parseFloat 只吃开头那段数字), + * 于是裁剪框变成 16:1 的细长条,而钩子本身不报错、只是裁出来的图没法看。 + * 头像的 '1:1' 恰好蒙对(=1),所以这个坑会潜伏到"启用题图"那天才炸。 + * 两种写法都支持:'16:9' / '16/9' 按分数算,纯数字按它本身算。 + */ + function parseRatioAttr(v) { + var s = String(v == null ? '' : v).trim(); + var m = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/.exec(s); + if (m) { + var a = parseFloat(m[1]), b = parseFloat(m[2]); + return (isFinite(a) && isFinite(b) && b > 0 && a > 0) ? a / b : 1; + } + var f = parseFloat(s); + return (isFinite(f) && f > 0) ? f : 1; + } + + function build() { + overlay = document.createElement('div'); + overlay.className = 'crop-overlay'; + overlay.innerHTML = + ''; + document.body.appendChild(overlay); + stage = overlay.querySelector('.crop-stage'); + imgEl = overlay.querySelector('.crop-img'); + boxEl = overlay.querySelector('.crop-box'); + + overlay.addEventListener('click', function (e) { + if (e.target === overlay) { close(false); return; } + var act = e.target.getAttribute && e.target.getAttribute('data-act'); + if (act === 'cancel') { close(false); } + if (act === 'ok') { close(true); } + }); + document.addEventListener('keydown', function (e) { + if (overlay && overlay.parentNode && e.key === 'Escape') { close(false); } + }); + bindDrag(overlay.querySelector('.crop-handle'), 'resize'); + bindDrag(boxEl, 'move'); + } + + /** 让元素可拖动。pointer 事件同时覆盖鼠标与触摸,不用分别写两套。 */ + function bindDrag(el, mode) { + el.addEventListener('pointerdown', function (e) { + if (!st || e.button > 0) { return; } + e.preventDefault(); + e.stopPropagation(); + try { el.setPointerCapture(e.pointerId); } catch (err) { /* 老浏览器忽略 */ } + var x0 = e.clientX, y0 = e.clientY; + var b0 = { x: boxEl.offsetLeft, y: boxEl.offsetTop, w: boxEl.offsetWidth, h: boxEl.offsetHeight }; + function onMove(ev) { + var dx = ev.clientX - x0, dy = ev.clientY - y0; + if (mode === 'move') { + place(b0.x + dx, b0.y + dy, b0.w, b0.h); + } else { + // 缩放时**锁定比例**:以宽为准推高,宽被夹住时高也跟着被夹住 + var nw = b0.w + dx; + place(b0.x, b0.y, nw, nw / st.ratio); + } + } + function onUp() { + el.removeEventListener('pointermove', onMove); + el.removeEventListener('pointerup', onUp); + el.removeEventListener('pointercancel', onUp); + } + el.addEventListener('pointermove', onMove); + el.addEventListener('pointerup', onUp); + el.addEventListener('pointercancel', onUp); + }); + } + + var MIN = 32; // 裁剪框最小边长(显示像素),太小了没法拖 + + /** 把裁剪框放到指定位置,并夹在图片范围内 */ + function place(x, y, w, h) { + w = Math.max(MIN, Math.min(w, st.dispW)); + h = Math.max(MIN * st.ratio, Math.min(h, st.dispH)); + // 比例锁定后再夹一次高度(宽高比可能因为上面的夹取而失配) + if (w / h > st.ratio) { w = h * st.ratio; } else { h = w / st.ratio; } + x = Math.max(0, Math.min(x, st.dispW - w)); + y = Math.max(0, Math.min(y, st.dispH - h)); + boxEl.style.left = x + 'px'; + boxEl.style.top = y + 'px'; + boxEl.style.width = w + 'px'; + boxEl.style.height = h + 'px'; + } + + function open(input, file) { + if (!overlay) { build(); } + var ratio = parseRatioAttr(input.getAttribute('data-crop-ratio')); + var url = URL.createObjectURL(file); + var probe = new Image(); + probe.onload = function () { + // 图片按「等比缩放到可用区域」后显示;裁剪框的坐标都相对这块显示区域, + // 最后再除以它换成 0..1 —— 所以原图多大都不影响结果。 + var availW = Math.min(window.innerWidth * 0.86, 620); + var availH = Math.min(window.innerHeight * 0.52, 420); + var scale = Math.min(availW / probe.naturalWidth, availH / probe.naturalHeight, 1); + var dispW = Math.max(1, Math.round(probe.naturalWidth * scale)); + var dispH = Math.max(1, Math.round(probe.naturalHeight * scale)); + + st = { input: input, ratio: ratio, dispW: dispW, dispH: dispH, + naturalW: probe.naturalWidth, naturalH: probe.naturalHeight, + previewSrc: null, outW: parseInt(input.getAttribute('data-crop-w'), 10) || dispW, + outH: parseInt(input.getAttribute('data-crop-h'), 10) || dispH }; + + imgEl.src = url; + stage.style.width = dispW + 'px'; + stage.style.height = dispH + 'px'; + var shape = overlay.querySelector('.crop-shape'); + shape.textContent = input.getAttribute('data-crop-label') || ''; + overlay.querySelector('.crop-tip').textContent = + input.getAttribute('data-crop-hint') || + '拖动方框挪位置,拖右下角改大小。框里就是最终会保存的样子。'; + + // 默认框 = 图里最大的、符合目标比例的那块(居中)—— 什么都不动直接确定, + // 效果就等于老的「居中裁切」,所以默认值是安全的。 + var w = dispW, h = w / ratio; + if (h > dispH) { h = dispH; w = h * ratio; } + place((dispW - w) / 2, (dispH - h) / 2, w, h); + + // 记住预览图原来的 src:取消时恢复,否则预览会停在"没裁过的图"上 + var prev = preview(input); + st.previewSrc = prev ? prev.src : null; + st._url = url; + overlay.classList.add('is-open'); + }; + probe.onerror = function () { + URL.revokeObjectURL(url); + alert('这张图片读不出来,换一张试试。'); + input.value = ''; + }; + probe.src = url; + } + + function preview(input) { + var sel = input.getAttribute('data-crop-preview'); + return sel ? document.querySelector(sel) : null; + } + + function close(ok) { + if (!st) { return; } + if (ok) { + var nx = boxEl.offsetLeft / st.dispW; + var ny = boxEl.offsetTop / st.dispH; + var nw = boxEl.offsetWidth / st.dispW; + var nh = boxEl.offsetHeight / st.dispH; + // 写回归一化矩形 —— 服务端按它裁,与源图分辨率无关 + var hidden = hiddenFor(st.input); + if (hidden) { + hidden.value = [nx, ny, nw, nh].map(function (v) { + return (Math.round(v * 1e6) / 1e6).toFixed(6); + }).join(','); + } + drawPreview(); + } else { + st.input.value = ''; // 取消 = 不换图,别让它悄悄传上去被居中裁 + var prev = preview(st.input); + if (prev && st.previewSrc !== null) { prev.src = st.previewSrc; } + var hid = hiddenFor(st.input); + if (hid) { hid.value = ''; } + } + overlay.classList.remove('is-open'); + if (st._url) { URL.revokeObjectURL(st._url); } + st = null; + } + + /** 按当前框把结果画出来当预览 —— 让用户看到"框里那块"长什么样 */ + function drawPreview() { + var prev = preview(st.input); + if (!prev) { return; } + var canvas = document.createElement('canvas'); + canvas.width = st.outW; + canvas.height = st.outH; + var g = canvas.getContext('2d'); + var sx = boxEl.offsetLeft / st.dispW * st.naturalW; + var sy = boxEl.offsetTop / st.dispH * st.naturalH; + var sw = boxEl.offsetWidth / st.dispW * st.naturalW; + var sh = boxEl.offsetHeight / st.dispH * st.naturalH; + try { + g.drawImage(imgEl, sx, sy, sw, sh, 0, 0, st.outW, st.outH); + prev.src = canvas.toDataURL('image/jpeg', 0.9); + } catch (err) { + // 跨域图片会让 canvas 变"污染",这里都是本地 ObjectURL,正常不会发生 + } + } + + /** 同表单里那个存裁剪矩形的隐藏字段 */ + function hiddenFor(input) { + var form = input.form; + if (!form) { return null; } + return form.querySelector('input[name="' + input.name + '_crop"]'); + } + + inputs.forEach(function (input) { + input.addEventListener('change', function () { + var f = input.files && input.files[0]; + if (!f) { return; } + if (!/^image\//.test(f.type)) { alert('请选择图片文件。'); input.value = ''; return; } + open(input, f); + }); + }); + })(); + + // 头像选好后即时预览(仅用于**没走裁剪器**的文件框,避免和裁剪预览打架) var fileInput = document.getElementById('avatar'); - if (fileInput && window.FileReader) { + if (fileInput && !fileInput.hasAttribute('data-crop-ratio') && window.FileReader) { fileInput.addEventListener('change', function () { var f = this.files && this.files[0]; if (!f) { return; } @@ -40,6 +271,16 @@ }); } + // 下拉菜单:点外面 / 按 Esc 就收起来(用
实现,没这段也能用) + Array.prototype.forEach.call(document.querySelectorAll('details.menu'), function (d) { + document.addEventListener('click', function (e) { + if (d.open && !d.contains(e.target)) { d.open = false; } + }); + document.addEventListener('keydown', function (e) { + if (d.open && e.key === 'Escape') { d.open = false; } + }); + }); + // 头像加载失败时换回内置剪影 —— 保证任何时候都不会出现破图 Array.prototype.forEach.call(document.querySelectorAll('img'), function (img) { img.addEventListener('error', function () { diff --git a/index.php b/index.php index 9b5206d..167192f 100644 --- a/index.php +++ b/index.php @@ -19,6 +19,7 @@ use App\Family; use App\Kinship; use App\Nickname; use App\Person; +use App\PersonGroup; use App\Relations; use App\Schema; use App\Setting; @@ -100,6 +101,36 @@ function read_birthday_input(array $existing = []): array ]; } +/** + * 渲染「个人设置」页(settings/me)。 + * + * 抽成函数是因为它有三处调用者:正常打开、以及资料表单校验失败时的两处 + * ("我的资料"和"改密码"各一次)—— 那三处的数据装配必须完全一致, + * 否则校验失败重画的那次会缺东西(比如分组顺序整块消失),且不会有任何报错。 + * + * @param array $person 当前登录者自己的名册记录 + * @param array $errors 字段名 => 错误文案 + */ +function render_me_settings(array $person, array $errors = []): void +{ + $familyId = (int)Auth::familyId(); + $me = (int)$person['id']; + View::render('settings/me', [ + 'title' => '个人设置', + 'person' => $person, + 'errors' => $errors, + 'familyMotto' => (string)(Family::find($familyId)['motto'] ?? ''), + // 我怎么称呼别人(视角属性,只有我自己能定) + 'nicknames' => Nickname::mineWithNames($familyId, $me), + 'options' => Person::options($familyId, $me, $me), + 'birthdays' => Birthday::describe($person), + // 名册分组:顺序是「我的」,分类是「全家的」 + 'groups' => PersonGroup::orderFor($familyId, $me), + 'groupCounts' => PersonGroup::counts($familyId, 'member'), + ], 'settings'); +} + + switch ($r) { // ==================== 登录 / 退出 ==================== @@ -245,38 +276,79 @@ switch ($r) { } unset($row); + // 按关系分组分栏。顺序用**当前用户自己的**(没自定义过就是默认顺序); + // 归到键 0 的那一堆是还没分类的人,模板单独放在最后一栏。 + $grouped = []; + foreach ($rows as $row) { + $grouped[(int)($row['group_id'] ?? 0)][] = $row; + } + View::render('people/index', [ 'title' => '家庭名册', 'people' => $rows, + 'grouped'=> $grouped, + 'groups' => PersonGroup::orderFor($familyId, $me), 'q' => $q, 'meId' => $me, ], 'people'); break; - // ==================== 我自己的资料 ==================== - // 点顶栏的头像/名字直接落到这里。访客也能改自己的资料 —— + /** + * 名册卡片上的「调整分组」。 + * + * 单开一个路由而不是让人去「编辑成员」里改,是因为那个表单很长 + * (三个生日、身份证号、住址…),为了挪一个分组去翻半页是折磨。 + * 只改 group_id 一列,所以走 Person::setGroup(),不会碰到别的字段。 + */ + case 'people/group': + $familyId = (int)Auth::familyId(); + if (!Auth::canWrite()) { + flash('err', '你的身份是访客,不能改名册里的分组。'); + redirect('people'); + } + csrf_check(); + $pid = (int)($_POST['person_id'] ?? 0); + $gid = (int)($_POST['group_id'] ?? 0); + $p = Person::find($pid); + if (!$p || (int)$p['family_id'] !== $familyId) { + flash('err', '找不到这个人。'); + redirect('people'); + } + // 分组必须也是这个家庭的 —— 否则可以把人挂到别人家的分组上, + // 名册按 group_id 分栏时就永远看不见他了 + if ($gid > 0 && !isset(PersonGroup::byId($familyId)[$gid])) { + flash('err', '找不到那个分组。'); + redirect('people'); + } + Person::setGroup($pid, $gid > 0 ? $gid : null); + flash('ok', $gid > 0 + ? '已把「' . $p['name'] . '」归到「' . PersonGroup::name($familyId, $gid) . '」。' + : '已把「' . $p['name'] . '」改成未分类。'); + redirect('people'); + break; + + // ==================== 我自己的资料 / 个人设置 ==================== + // 顶栏头像点开 →「个人设置」。访客也能改自己的资料 —— // 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。 case 'people/me': + // 「我的资料」已并进「个人设置」(设置分两个维度:系统设置 / 个人设置)。 + // 旧链接、老书签、以及别处写死的 people/me 一律跳过去,不再留第二个入口。 + redirect('settings/me'); + break; + + case 'settings/me': Auth::requireLogin(); - $me = (int)Auth::personId(); - $p = Person::find($me); + $p = Person::find((int)Auth::personId()); if (!$p) { flash('err', '找不到你自己的名册记录。'); redirect('home'); } clear_old(); - View::render('people/me', [ - 'title' => '我的资料', - 'person' => $p, - 'errors' => [], - 'familyMotto'=> (string)(Family::find((int)Auth::familyId())['motto'] ?? ''), - 'nicknames' => Nickname::mineWithNames((int)Auth::familyId(), $me), - 'options' => Person::options((int)Auth::familyId(), $me, $me), - 'birthdays' => Birthday::describe($p), - ], 'people'); + render_me_settings($p); break; - case 'people/me_save': + case 'settings/me_save': // 表单现在提交到这里 + case 'people/me_save': // 兼容旧链接/旧标签页里还没刷新的表单 Auth::requireLogin(); csrf_check(); $me = (int)Auth::personId(); @@ -296,21 +368,15 @@ switch ($r) { ] + read_birthday_input($p); $errors = Person::validate($d); - $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []); + // 归一化裁剪框由前端浮层写进 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); - View::render('people/me', [ - 'title' => '我的资料', - 'person' => $p, - 'errors' => $errors, - 'familyMotto'=> (string)(Family::find((int)Auth::familyId())['motto'] ?? ''), - 'nicknames' => Nickname::mineWithNames((int)Auth::familyId(), $me), - 'options' => Person::options((int)Auth::familyId(), $me, $me), - 'birthdays' => Birthday::describe($p), - ], 'people'); + render_me_settings($p, $errors); exit; } if ($up['ok']) { @@ -327,7 +393,85 @@ switch ($r) { Auth::refreshAvatar((string)$d['avatar']); clear_old(); flash('ok', '已保存。'); - redirect('people/me'); + redirect('settings/me'); + break; + + // ==================== 分组顺序(只影响我自己) ==================== + case 'settings/groups/move': + Auth::requireLogin(); + csrf_check(); + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + $id = (int)($_POST['id'] ?? 0); + $dir = ($_POST['dir'] ?? '') === 'up' ? 'up' : 'down'; + + $list = PersonGroup::orderFor($familyId, $me); + $ids = array_map(fn($g) => (int)$g['id'], $list); + $at = array_search($id, $ids, true); + + if ($at === false) { + flash('err', '找不到这个分组。'); + redirect('settings/me'); + } + // 挪到边界就什么也不做(按钮在两端本来就是 disabled 的, + // 但地址栏里手敲一次也能到这儿,别让它把顺序搞乱) + $to = $dir === 'up' ? $at - 1 : $at + 1; + if ($to >= 0 && $to < count($ids)) { + [$ids[$at], $ids[$to]] = [$ids[$to], $ids[$at]]; + PersonGroup::saveOrder($familyId, $me, $ids); + } + redirect('settings/me'); + break; + + // ==================== 关系分类的增删改名(全家共用) ==================== + // 分类写在共享的表里,所以是"能写"的人才能动;访客只读,给个明确的说法再回去, + // 而不是甩一张 403 页面(他明明是点了自己设置里的按钮)。 + case 'settings/groups/add': + Auth::requireLogin(); + csrf_check(); + if (!Auth::canWrite()) { + flash('err', '你的身份是访客,不能改关系分类。'); + redirect('settings/me'); + } + $res = PersonGroup::add((int)Auth::familyId(), (string)($_POST['name'] ?? '')); + flash($res['ok'] ? 'ok' : 'err', $res['ok'] + ? '已加上分类「' . trim((string)($_POST['name'] ?? '')) . '」。' + : (string)($res['error'] ?? '加不上。')); + redirect('settings/me'); + break; + + case 'settings/groups/rename': + Auth::requireLogin(); + csrf_check(); + if (!Auth::canWrite()) { + flash('err', '你的身份是访客,不能改关系分类。'); + redirect('settings/me'); + } + $res = PersonGroup::rename((int)Auth::familyId(), (int)($_POST['id'] ?? 0), + (string)($_POST['name'] ?? '')); + flash($res['ok'] ? 'ok' : 'err', $res['ok'] + ? '分类名已改。' + : (string)($res['error'] ?? '改不了。')); + redirect('settings/me'); + break; + + case 'settings/groups/delete': + Auth::requireLogin(); + csrf_check(); + if (!Auth::canWrite()) { + flash('err', '你的身份是访客,不能改关系分类。'); + redirect('settings/me'); + } + $res = PersonGroup::delete((int)Auth::familyId(), (int)($_POST['id'] ?? 0)); + if ($res['ok']) { + $n = (int)($res['moved'] ?? 0); + flash('ok', $n > 0 + ? '分类已删掉,那 ' . $n . ' 个人现在显示在「未分类」里,去名册重新归类即可。' + : '分类已删掉(那一组本来也没人)。'); + } else { + flash('err', (string)($res['error'] ?? '删不了。')); + } + redirect('settings/me'); break; // ==================== 别人怎么称呼(只有我自己能定) ==================== @@ -348,8 +492,9 @@ switch ($r) { flash('ok', trim($nick) === '' ? '已恢复成用姓名显示。' : '记下了:你叫他「' . trim($nick) . '」。这只影响你自己看到的称呼。'); - if ($back === 'people/me') { - redirect('people/me'); + if ($back === 'settings/me' || $back === 'people/me') { + // 两个写法都认:老表单发的是 people/me(那一页现在是跳转) + redirect('settings/me'); } redirect('people/show', ['id' => $target]); break; @@ -361,6 +506,7 @@ switch ($r) { 'title' => '添加成员', 'person' => null, 'errors' => [], + 'groups' => PersonGroup::byId((int)Auth::familyId()), ], 'people'); break; @@ -383,10 +529,13 @@ switch ($r) { // 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。 'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')), 'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0, + // 关系分组:下拉里的「未分类」是空 value,于是这里落成 null + 'group_id' => !empty($_POST['group_id']) ? (int)$_POST['group_id'] : null, ] + read_birthday_input(); $errors = Person::validate($d); - $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []); + $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? [], + (string)($_POST['avatar_crop'] ?? '')); if (!$up['ok'] && ($up['error'] ?? '') !== '') { $errors['avatar'] = $up['error']; } @@ -398,6 +547,7 @@ switch ($r) { 'errors' => $errors, 'phoneValue' => $d['phone'], 'idCardValue'=> $d['id_card'], + 'groups' => PersonGroup::byId((int)Auth::familyId()), ], 'people'); exit; } @@ -414,6 +564,7 @@ switch ($r) { 'errors' => ['phone' => '这个手机号已经在名册里了'], 'phoneValue' => $d['phone'], 'idCardValue'=> $d['id_card'], + 'groups' => PersonGroup::byId((int)Auth::familyId()), ], 'people'); exit; } @@ -436,6 +587,7 @@ switch ($r) { 'errors' => [], 'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']), 'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']), + 'groups' => PersonGroup::byId((int)Auth::familyId()), ], 'people'); break; @@ -461,9 +613,11 @@ switch ($r) { 'avatar' => (string)$p['avatar'], 'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')), 'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0, + 'group_id' => !empty($_POST['group_id']) ? (int)$_POST['group_id'] : null, ] + read_birthday_input($p); $errors = Person::validate($d); - $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []); + $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? [], + (string)($_POST['avatar_crop'] ?? '')); if (!$up['ok'] && ($up['error'] ?? '') !== '') { $errors['avatar'] = $up['error']; } @@ -475,6 +629,7 @@ switch ($r) { 'errors' => $errors, 'phoneValue' => $d['phone'], 'idCardValue' => $d['id_card'], + 'groups' => PersonGroup::byId((int)Auth::familyId()), ], 'people'); exit; } @@ -492,6 +647,7 @@ switch ($r) { 'errors' => ['phone' => '这个手机号已经被名册里的另一个人用了'], 'phoneValue' => $d['phone'], 'idCardValue' => $d['id_card'], + 'groups' => PersonGroup::byId((int)Auth::familyId()), ], 'people'); exit; } diff --git a/install.php b/install.php index 5ca71e2..72f72fa 100644 --- a/install.php +++ b/install.php @@ -54,6 +54,9 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { // 所以老库重新跑一次就能自动升级,不必手工改表。 Schema::ensure(); $familyId = Family::create($familyName, $motto); + // 关系分组的预置值:在这里建好,免得「装完第一眼进名册发现一个分组都没有」 + // (ensure() 里也会补,但那要等下一次请求) + \App\PersonGroup::seedDefaults((int)$familyId); // 装完就把结构版本盖上:不盖的话,第一次进首页发现"版本对不上", // 会再跑一次 ensure()(幂等,不出错,但白跑一遍 ALTER 查询)。 \App\Setting::set($familyId, 'schema_version', Schema::VERSION); @@ -72,6 +75,11 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { ]); Account::create($personId, $password, 'admin'); + // 创始人(=第一个管理员)默认归到「家人」。 + // 用 backfillMissing 而不是自己算组 id:规则「member → 家人」只写在一处, + // 安装与老库升级才不会有第二种说法。 + \App\PersonGroup::backfillMissing(Database::get()); + // 装完就删掉自己 —— 避免把安装入口长期暴露在公网 @unlink(__FILE__); diff --git a/templates/home.php b/templates/home.php index 04dfcc6..23d27c2 100644 --- a/templates/home.php +++ b/templates/home.php @@ -53,7 +53,7 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => ' 我的一句话 - +

diff --git a/templates/layout.php b/templates/layout.php index 979370e..32171db 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -41,17 +41,27 @@ $isLoggedIn = \App\Auth::check();
- :想改自己的信息得绕 - 「名册 → 找到自己 → 编辑资料」三步,而最该一眼可达的 - 恰恰是「改我自己」这件事。 */ ?> - - - - 访客 - - 改密码 - 退出 + 实现:不需要 JS 就能展开/收起,app.js 只额外补 + "点外面"和"Esc"两件事。 */ ?> +
diff --git a/templates/people/form.php b/templates/people/form.php index 2f2f7fb..23bcc44 100644 --- a/templates/people/form.php +++ b/templates/people/form.php @@ -72,6 +72,24 @@ $idBd = trim((string)($person['birthday_id_card'] ?? '')); 小的排前面。 + +
+ + + + 名册会按这个分组分栏显示。分组不够用就自己在「个人设置」里加。 + +
+
@@ -173,10 +191,23 @@ $idBd = trim((string)($person['birthday_id_card'] ?? ''));
+
- - 会自动裁成正方形,上限 5MB。 + + + + 选好图片后会弹出裁剪框,把要留的部分框住。比例按它在页面上的形状定(头像是 1:1 正方形)。上限 5MB。
diff --git a/templates/people/index.php b/templates/people/index.php index 1cd55de..7c7e785 100644 --- a/templates/people/index.php +++ b/templates/people/index.php @@ -1,6 +1,105 @@ + a + details,**不能写成 a > form**: + * 表单套在链接里是非法 HTML,浏览器会把 DOM 拆开,表单里那个「移动」按钮 + * 可能连提交机会都没有(或者把整张卡片点成跳转)。 + */ +$canMove = \App\Auth::canWrite(); +$total = 0; +foreach ($grouped as $list) { + $total += count($list); +} + +/** + * 画一栏人。用闭包是因为"分好组的各栏"和"未分类"两处要画同一套卡片, + * 复制一遍迟早会改漏一处。 + */ +$renderGrid = function (array $list) use ($groups, $meId, $canMove): void { + if (!$list) { + return; + } + ?> + + +

家庭名册

- 共 人 + 共 人
添加成员 @@ -22,47 +121,42 @@
+ + +

+ 找到 个匹配「」的人(搜索时不分组)。 +

+ - + + +
+

+ + 人 +

+ +
+ + + +
+

+ 未分类 + 人 +

+

+ 这些人还没归到任何分组里。管理员可以在卡片下面直接选一个分组(或去「编辑成员」里改)。 +

+ +
+ diff --git a/templates/people/show.php b/templates/people/show.php index 0f4e21b..5e205b1 100644 --- a/templates/people/show.php +++ b/templates/people/show.php @@ -52,7 +52,7 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' =
- 改我自己的资料 + 改我自己的资料 编辑资料 diff --git a/templates/settings/_nicknames.php b/templates/settings/_nicknames.php new file mode 100644 index 0000000..5d8d9ad --- /dev/null +++ b/templates/settings/_nicknames.php @@ -0,0 +1,45 @@ + +
+

+ 我怎么称呼家人 + 只影响你自己看到的叫法 +

+

+ 同一个「小明」,妈妈叫"明明"、姐姐叫"小弟" —— 称呼是**每个人各自定的**, + 不是这个人自带的属性。所以这里改的只影响你自己的页面,别人看到的还是他们自己的叫法。 + 留空 = 恢复成用姓名显示。 +

+ + +

名册里还没有别人。

+ +
+ +
+ + + + + + + + + +
+ +
+ +
diff --git a/templates/people/me.php b/templates/settings/_profile.php similarity index 69% rename from templates/people/me.php rename to templates/settings/_profile.php index 22d0515..0a68311 100644 --- a/templates/people/me.php +++ b/templates/settings/_profile.php @@ -1,40 +1,26 @@ nickname -$myNicks = []; -foreach ($nicknames as $n) { - $myNicks[(int)$n['target_id']] = (string)$n['nickname']; -} +// 裁剪比例由「这个位置显示成什么形状」决定,前后端共用同一份配置(见 App\ImageSlot) +$avSlot = \App\ImageSlot::get('avatar'); ?> -
-

我的资料

- 你自己看到的样子 · 别人看到你的名字不变 - -
- -
+
@@ -137,8 +123,20 @@ foreach ($nicknames as $n) {
- - 会自动裁成正方形,上限 5MB。 + + + + + 选好图片后会弹出裁剪框,把要留的部分框住。比例按它在页面上的形状定 + (头像是 1:1 正方形)。上限 5MB。 + +
@@ -153,37 +151,3 @@ foreach ($nicknames as $n) {
- - -
-

- 我怎么称呼家人 - 只影响你自己看到的叫法 -

-

- 同一个「小明」,妈妈叫"明明"、姐姐叫"小弟" —— 称呼是**每个人各自定的**, - 不是这个人自带的属性。所以这里改的只影响你自己的页面,别人看到的还是他们自己的叫法。 - 留空 = 恢复成用姓名显示。 -

- - -

名册里还没有别人。

- -
- -
- - - - - - - - - -
- -
- -
diff --git a/templates/settings/me.php b/templates/settings/me.php new file mode 100644 index 0000000..db39822 --- /dev/null +++ b/templates/settings/me.php @@ -0,0 +1,120 @@ + +
+

个人设置

+ 只影响你自己看到的样子 + +
+ +

我的资料 姓名、头像、生日、联系方式

+ + +
+ + + +
+

+ 名册分组顺序 + 只影响你自己看到的顺序 +

+

+ 名册是按「关系分组」分栏显示的。分组本身是全家共用的一套, + 但哪一组排前面是你自己的习惯 —— 你在这里调,别人看到的顺序不变。 + 1): ?>
用 ▲ ▼ 把它挪到你要的位置,挪一下就存一次。 +

+ + +

还没有分组。

+ +
    + $g): $gid = (int)$g['id']; ?> +
  1. + + + 人 +
    + + + + +
    +
  2. + +
+ +
+ + +
+

+ 关系分类管理 + 分类是全家共用的 +

+

+ 预置了「家人 / 亲戚 / 同事 / 朋友 / 合作伙伴 / 认识 / 不熟」七个。 + 不够用就自己加,叫法不合适就改名。 + 分类是全家共用的一套(你加的,家里人都会看到)—— + 因为「老杨的同事」对全家来说都是同事,各人各定一套会让名册没法看。 +
+ 删掉一个分类不会删掉人:那一组人会变成「未分类」,在名册里提醒你重新归类。 +

+ +
+ + + + +
+ + +
    + +
  • +
    + + + + + +
    + 人 + ),不用 JS 也能防手滑 —— 一点就没了太容易出事 */ ?> +
    + 删除 +
    + + + 这组的人会变成「未分类」,确定? + +
    +
    +
  • + +
+ +