Files
FamilyBook/app/ImageSlot.php
T
laoyang 1c460a0e83 图片可裁剪 · 名册按关系分组 · 个人设置独立 · 顶栏下拉菜单
① 上传图片一律可裁剪,比例由图位决定
   新增 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 逐字节一致。
2026-09-30 11:06:22 +08:00

83 lines
3.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 上传位置(「槽位」)的声明式配置。
*
* 为什么要有这个类:**比例是由「这张图显示出来是什么形状」决定的**,不是由上传者决定的。
* 头像在页面上永远是圆的/圆角方的 → 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];
}
}