图片可裁剪 · 名册按关系分组 · 个人设置独立 · 顶栏下拉菜单

① 上传图片一律可裁剪,比例由图位决定
   新增 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 藏掉导致改密码/退出不可达;改成 <details> 零 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 逐字节一致。
This commit is contained in:
2026-09-30 11:06:22 +08:00
parent 7e9b4520c0
commit 1c460a0e83
18 changed files with 1637 additions and 182 deletions
+82
View File
@@ -0,0 +1,82 @@
<?php
/**
* 上传位置(「槽位」)的声明式配置。
*
* 为什么要有这个类:**比例是由「这张图显示出来是什么形状」决定的**,不是由上传者决定的。
* 头像在页面上永远是圆的/圆角方的 → 1:1;宽幅题图是横的 → 16:9。
* 把这些写在一处,前端拿它去做裁剪框的锁定比例、服务端拿它去做最终裁切,
* 两边就不会各写一个数字然后慢慢漂移。
*
* 加一个新位置(比如题图)只需要在 SLOTS 里加一行,前端与服务端都不用改:
* 'cover' => ['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/<dir>/,云端就是桶里的 <prefix>/<dir>/。
* `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<string,mixed>|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];
}
}