Files
FamilyBook/app/ImageSlot.php
T
laoyang ff353b1eee v8:观影落地(MovieLog 整体迁入统一事件原语)+ 三处 UI 撤除
- Schema v8:events / movie_details / event_attendees / event_photos 四张表,
  uk_event_source(family_id, source_ref) 作迁移幂等锚
- Movie 模型:时间线/详情/增删改、同行人整组替换、照片逐张管理、感受英→中映射
- ImageSlot 新增 poster(2:3) / ticket(free) / photo(free) 三槽位;
  free 槽位不裁剪、整图等比缩进框(只缩不放大)
- OssDriver::copyObject:同桶 CopyObject(手写 V1 签名,x-oss-* 头按字典序进签名串)
- 路由:movies 时间线/详情/新增/编辑/删除/补照片/删照片;同行人按家庭过滤;
  访客只读(requireWrite)
- UI 撤除:首页「我的一句话」、家谱页「补充我的亲属关系 / 看整个名册」两个按钮;
  顶栏加「观影」入口
- 修潜伏 bug:ImageSlot::parseRatio 的正则用 / 作分隔符,字符类里的 / 把模式
  提前截断(Unknown modifier ']')—— ratio() 只在上传 POST 里被调到,一直没被观察到
- 线上数据迁移(临时 token 门禁页,用完即删):MovieLog 219 条观影 → events,
  14 位观影人新增进名册(带 MovieLog 头像),431 个 OSS 对象同桶拷贝
  (movielog/uploads/<dir>/x → family/<dir>/x),review/feelings/影院三段/照片全量搬
2026-09-30 20:22:46 +08:00

120 lines
4.8 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
{
// ⚠️ 应用层不再设「兆数」上限(用户原话:上传照片不要有大小限制)。
// 服务器 ini(upload_max_filesize / post_max_size,线上是 50M)是唯一的天花板;
// 像素与内存的守卫在 Upload::image() 里按图动态算。
// 输出尺寸恒为槽位的 w×h(头像 512×512)——原图再大,落库的都是小 JPEG。
/**
* 位置 → 比例与输出尺寸。
*
* `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 裁。',
],
// v8 观影三槽位。海报是竖版的(豆瓣海报约 2:3)→ 裁剪;
// 票根/现场照片是随手拍、比例五花八门 → ratio='free':**不裁剪**,
// 等比缩到框内(w×h 是「最长边」而不是固定输出尺寸),服务端保证不变形。
'poster' => [
'label' => '海报',
'ratio' => '2:3',
'w' => 600,
'h' => 900,
'dir' => 'posters',
'hint' => '竖版海报,按 2:3 裁。没裁就按整图居中取。',
],
'ticket' => [
'label' => '票根',
'ratio' => 'free',
'w' => 1400,
'h' => 1400,
'dir' => 'tickets',
'hint' => '票根不裁剪,等比缩到最长边 1400。',
],
'photo' => [
'label' => '照片',
'ratio' => 'free',
'w' => 1600,
'h' => 1600,
'dir' => 'photos',
'hint' => '现场照片不裁剪,等比缩到最长边 1600。',
],
// 题图按 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…,'free' → 0.0(不锁比例、不裁剪) */
public static function ratio(array $slot): float
{
if (($slot['ratio'] ?? '') === 'free') {
return 0.0;
}
[$a, $b] = self::parseRatio((string)($slot['ratio'] ?? '1:1'));
return $b > 0 ? $a / $b : 1.0;
}
/** 给前端用的 CSS aspect-ratio 写法:'1 / 1'、'16 / 9';free 槽位返回空串(前端不进裁剪器) */
public static function ratioCss(array $slot): string
{
if (($slot['ratio'] ?? '') === 'free') {
return '';
}
[$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
{
// ⚠️ 分隔符别用 /:'[:/]' 里的斜杠会把模式提前截断("Unknown modifier ']'"),
// 这个潜伏 bug 一直没炸是因为 ratio() 只在上传 POST 里被调到(302 正文没人扫)。
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];
}
}