From ff353b1eee5af628eec277f52614392e9a85323a Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 30 Sep 2026 20:22:46 +0800 Subject: [PATCH] =?UTF-8?q?v8=EF=BC=9A=E8=A7=82=E5=BD=B1=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=EF=BC=88MovieLog=20=E6=95=B4=E4=BD=93=E8=BF=81=E5=85=A5?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=BA=8B=E4=BB=B6=E5=8E=9F=E8=AF=AD=EF=BC=89?= =?UTF-8?q?+=20=E4=B8=89=E5=A4=84=20UI=20=E6=92=A4=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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//x → family//x),review/feelings/影院三段/照片全量搬 --- app/ImageSlot.php | 43 +++++- app/Movie.php | 281 +++++++++++++++++++++++++++++++++++++ app/Schema.php | 68 ++++++++- app/Storage/OssDriver.php | 48 +++++++ app/Upload.php | 38 +++-- assets/css/app.css | 71 ++++++++++ index.php | 223 +++++++++++++++++++++++++++++ templates/home.php | 22 +-- templates/kin/tree.php | 5 - templates/layout.php | 4 +- templates/movies/form.php | 157 +++++++++++++++++++++ templates/movies/index.php | 61 ++++++++ templates/movies/show.php | 134 ++++++++++++++++++ 13 files changed, 1112 insertions(+), 43 deletions(-) create mode 100644 app/Movie.php create mode 100644 templates/movies/form.php create mode 100644 templates/movies/index.php create mode 100644 templates/movies/show.php diff --git a/app/ImageSlot.php b/app/ImageSlot.php index 9ac2e00..19b2ac0 100644 --- a/app/ImageSlot.php +++ b/app/ImageSlot.php @@ -41,7 +41,34 @@ final class ImageSlot 'dir' => 'avatars', 'hint' => '正方形。页面上显示成圆形或圆角方形,所以按 1:1 裁。', ], - // 题图按 16:9 —— 宽屏横幅。现在还没有位置用它,需要时把它启用即可: + // 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' => '宽屏横幅,显示在详情页顶部。'], ]; @@ -57,16 +84,22 @@ final class ImageSlot return self::SLOTS[$key] ?? null; } - /** 宽高比(宽 / 高)。'1:1' → 1.0,'16:9' → 1.777… */ + /** 宽高比(宽 / 高)。'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' */ + /** 给前端用的 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; } @@ -74,7 +107,9 @@ final class ImageSlot /** @return array{0:int,1:int} */ private static function parseRatio(string $ratio): array { - if (preg_match('/^\s*(\d+)\s*[:/]\s*(\d+)\s*$/', $ratio, $m)) { + // ⚠️ 分隔符别用 /:'[:/]' 里的斜杠会把模式提前截断("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]; diff --git a/app/Movie.php b/app/Movie.php new file mode 100644 index 0000000..c62223e --- /dev/null +++ b/app/Movie.php @@ -0,0 +1,281 @@ +', + * 重跑迁移按它先删后插,不会出现一半新一半旧的中间态。 + */ + +declare(strict_types=1); + +namespace App; + +final class Movie +{ + /** 观后感受标签(MovieLog 迁移时把英文键映射成中文,这里就是唯一清单) */ + public const FEELINGS = ['温暖', '激动', '欢乐', '深思', '伤感', '震撼', '治愈', '无聊']; + + /** MovieLog 的英文感受键 → 中文标签(没见过的键原样保留) */ + public const FEELING_MAP = [ + 'warm' => '温暖', + 'excited' => '激动', + 'thoughtful' => '深思', + 'sad' => '伤感', + 'funny' => '欢乐', + 'happy' => '欢乐', + 'boring' => '无聊', + 'moved' => '治愈', + 'shocked' => '震撼', + ]; + + /** @return array 按年份倒序的全部观影记录(时间线用) */ + public static function all(int $familyId): array + { + $st = Database::get()->prepare( + 'SELECT e.id, e.title, e.starts_at, e.location, e.note, + d.poster_key, d.ticket_key, d.rating, d.cinema_name, d.cinema_place, + (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS attendee_count, + (SELECT GROUP_CONCAT(p.name ORDER BY a.position, p.id SEPARATOR "、") + FROM event_attendees a JOIN persons p ON p.id = a.person_id + WHERE a.event_id = e.id) AS attendee_names + FROM events e + LEFT JOIN movie_details d ON d.event_id = e.id + WHERE e.family_id = ? AND e.kind = "movie" + ORDER BY e.starts_at DESC, e.id DESC' + ); + $st->execute([$familyId]); + return $st->fetchAll(); + } + + /** 一条完整记录(详情/编辑页用):事件 + 卫星 + 同行人 + 照片 */ + public static function find(int $familyId, int $id): ?array + { + $st = Database::get()->prepare( + 'SELECT e.*, d.info, d.poster_key, d.ticket_key, d.rating, d.review, d.feelings, + d.cinema_place, d.cinema_name, d.cinema_address + FROM events e + LEFT JOIN movie_details d ON d.event_id = e.id + WHERE e.family_id = ? AND e.id = ? AND e.kind = "movie"' + ); + $st->execute([$familyId, $id]); + $row = $st->fetch(); + if (!$row) { + return null; + } + $row['attendees'] = self::attendees($id); + $row['photos'] = self::photos($id); + return $row; + } + + /** @return array 同行的人 */ + public static function attendees(int $eventId): array + { + $st = Database::get()->prepare( + 'SELECT p.id, p.name, p.avatar + FROM event_attendees a JOIN persons p ON p.id = a.person_id + WHERE a.event_id = ? + ORDER BY a.position, p.id' + ); + $st->execute([$eventId]); + return $st->fetchAll(); + } + + public static function photos(int $eventId): array + { + $st = Database::get()->prepare( + 'SELECT id, `key` FROM event_photos WHERE event_id = ? ORDER BY position, id' + ); + $st->execute([$eventId]); + return $st->fetchAll(); + } + + /** + * 新建一条观影记录,返回事件 id。 + * $d 字段:title, starts_at(Y-m-d), location, note, info, rating, review, + * feelings(array), cinema_place, cinema_name, cinema_address, + * poster_key, ticket_key, attendees(int[]), photos(string[]) + */ + public static function create(int $familyId, ?int $creatorId, array $d): int + { + $db = Database::get(); + $db->prepare( + 'INSERT INTO events (family_id, creator_id, kind, title, starts_at, location, note, source_ref) + VALUES (?, ?, "movie", ?, ?, ?, ?, ?)' + )->execute([ + $familyId, $creatorId, (string)$d['title'], self::dateOrNull($d['starts_at'] ?? ''), + self::locOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''), + $d['source_ref'] ?? null, + ]); + $id = (int)$db->lastInsertId(); + self::saveDetails($id, $d); + self::saveAttendees($id, $d['attendees'] ?? []); + self::savePhotos($id, $d['photos'] ?? []); + return $id; + } + + public static function update(int $familyId, int $id, array $d): void + { + $db = Database::get(); + $db->prepare( + 'UPDATE events SET title = ?, starts_at = ?, location = ?, note = ? WHERE id = ? AND family_id = ?' + )->execute([ + (string)$d['title'], self::dateOrNull($d['starts_at'] ?? ''), + self::locOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''), + $id, $familyId, + ]); + self::saveDetails($id, $d); + self::saveAttendees($id, $d['attendees'] ?? []); + // photos 由单独的「删除某张」路由维护,编辑保存不整组替换 + } + + /** 卫星表 upsert(编辑时可能还没建过这行) */ + private static function saveDetails(int $eventId, array $d): void + { + $db = Database::get(); + $feel = array_values(array_filter(array_map( + static fn($f) => mb_substr(trim((string)$f), 0, 20, 'UTF-8'), + (array)($d['feelings'] ?? []) + ), static fn($f) => $f !== '')); + $row = [ + self::textOrNull($d['info'] ?? ''), + self::keyOrNull($d['poster_key'] ?? ''), + self::keyOrNull($d['ticket_key'] ?? ''), + isset($d['rating']) && $d['rating'] !== '' ? max(0, min(10, (int)$d['rating'])) : null, + self::textOrNull($d['review'] ?? ''), + $feel ? json_encode($feel, JSON_UNESCAPED_UNICODE) : null, + self::locOrNull($d['cinema_place'] ?? ''), + self::locOrNull($d['cinema_name'] ?? ''), + self::textOrNull($d['cinema_address'] ?? ''), + ]; + $has = $db->prepare('SELECT COUNT(*) FROM movie_details WHERE event_id = ?'); + $has->execute([$eventId]); + if ((int)$has->fetchColumn() > 0) { + $db->prepare( + 'UPDATE movie_details SET info=?, poster_key=?, ticket_key=?, rating=?, review=?, + feelings=?, cinema_place=?, cinema_name=?, cinema_address=? WHERE event_id=?' + )->execute([...$row, $eventId]); + } else { + $db->prepare( + 'INSERT INTO movie_details (info, poster_key, ticket_key, rating, review, + feelings, cinema_place, cinema_name, cinema_address, event_id) + VALUES (?,?,?,?,?,?,?,?,?,?)' + )->execute([...$row, $eventId]); + } + } + + /** 整组替换同行人(与 Person::replaceContacts 同一思路:一行不剩、一行不多) */ + public static function saveAttendees(int $eventId, array $personIds): void + { + $db = Database::get(); + $db->prepare('DELETE FROM event_attendees WHERE event_id = ?')->execute([$eventId]); + $ins = $db->prepare( + 'INSERT IGNORE INTO event_attendees (event_id, person_id, position) VALUES (?, ?, ?)' + ); + $pos = 0; + foreach ($personIds as $pid) { + $pid = (int)$pid; + if ($pid <= 0) { + continue; + } + $pos += 10; + $ins->execute([$eventId, $pid, $pos]); + } + } + + /** 整组替换现场照片(导入用;表单的「删一张」走 deletePhoto) */ + public static function savePhotos(int $eventId, array $keys): void + { + $db = Database::get(); + $db->prepare('DELETE FROM event_photos WHERE event_id = ?')->execute([$eventId]); + $ins = $db->prepare('INSERT INTO event_photos (event_id, `key`, position) VALUES (?, ?, ?)'); + $pos = 0; + foreach ($keys as $k) { + $k = trim((string)$k); + if ($k === '') { + continue; + } + $pos += 10; + $ins->execute([$eventId, $k, $pos]); + } + } + + /** 删一张现场照片(连带删存储对象) */ + public static function deletePhoto(int $familyId, int $eventId, int $photoId): void + { + $st = Database::get()->prepare('SELECT `key` FROM event_photos WHERE id = ? AND event_id = ?'); + $st->execute([$photoId, $eventId]); + $key = (string)($st->fetchColumn() ?: ''); + Database::get()->prepare('DELETE FROM event_photos WHERE id = ? AND event_id = ?') + ->execute([$photoId, $eventId]); + if ($key !== '') { + Storage\Storage::delete($familyId, $key); + } + } + + /** 删除整条观影记录(events 级联带走卫星/同行人/照片行;存储对象手动清) */ + public static function destroy(int $familyId, int $id): void + { + $keys = []; + foreach (self::photos($id) as $p) { + $keys[] = (string)$p['key']; + } + $st = Database::get()->prepare( + 'SELECT poster_key, ticket_key FROM movie_details WHERE event_id = ?' + ); + $st->execute([$id]); + if ($row = $st->fetch()) { + foreach ([$row['poster_key'], $row['ticket_key']] as $k) { + if ($k) { + $keys[] = (string)$k; + } + } + } + Database::get()->prepare('DELETE FROM events WHERE id = ? AND family_id = ? AND kind = "movie"') + ->execute([$id, $familyId]); + foreach ($keys as $k) { + Storage\Storage::delete($familyId, $k); + } + } + + // ---------------- 小工具 ---------------- + + private static function dateOrNull(string $v): ?string + { + $v = trim($v); + if ($v === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) { + return null; + } + return $v; + } + + private static function locOrNull(string $v): ?string + { + $v = mb_substr(trim($v), 0, 200, 'UTF-8'); + return $v !== '' ? $v : null; + } + + private static function textOrNull(string $v): ?string + { + $v = trim($v); + return $v !== '' ? $v : null; + } + + /** 存储键只留安全字符(相对键,不带路径穿越),空串回 NULL */ + private static function keyOrNull(string $v): ?string + { + $v = ltrim(trim($v), '/'); + if ($v === '' || strpos($v, '..') !== false || strpos($v, "\0") !== false) { + return null; + } + return $v; + } +} diff --git a/app/Schema.php b/app/Schema.php index 195b209..b91a510 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -37,8 +37,13 @@ final class Schema * 升级时按已有关系回填。起因:家谱/首页按关系推导可见,名册按分享推导 * —— 同一个人三处口径打架(配偶能在家谱看到孩子、名册里却没有)。 * 用户拍板:树上的人三处都可见,没上树的都不可见。 + * 8 → **归一事件原语落地(观影先行)**:events(统一事件:标题/日期/地点/ + * 来源引用 source_ref)+ movie_details(卫星表:海报/票根/评分/影评/ + * 影院三段)+ event_attendees(参与人)+ event_photos(现场照片)。 + * MovieLog 的 219 部观影记录整体迁入(source_ref='movielog:movie:', + * 幂等重跑的锚点)。日历本/CalDAV 是后续阶段,events 先不带 calendar_id。 */ - public const VERSION = '7'; + public const VERSION = '8'; /** @return string[] */ public static function statements(): array @@ -193,6 +198,67 @@ CREATE TABLE IF NOT EXISTS `person_contacts` ( KEY `idx_pc_person` (`person_id`, `kind`, `position`), CONSTRAINT `fk_pc_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成员的联系方式(可多条):电话 / 邮箱 / 地址 / 网址,对齐 vCard' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `events` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `creator_id` INT UNSIGNED DEFAULT NULL COMMENT '谁记的(persons.id);系统迁移可为 NULL', + `kind` ENUM('movie') NOT NULL COMMENT '事件类型:movie=观影(trip/任务等是后续阶段的卫星表)', + `title` VARCHAR(200) NOT NULL COMMENT '事件标题(观影=片名)', + `starts_at` DATE DEFAULT NULL COMMENT '发生日期(观影=观影日期;精确到时刻的需求等日历阶段再升 DATETIME)', + `location` VARCHAR(200) DEFAULT NULL COMMENT '地点(观影=影院名)', + `note` TEXT DEFAULT NULL COMMENT '备注', + `source_ref` VARCHAR(64) DEFAULT NULL COMMENT '外部来源引用(如 movielog:movie:123)——迁移幂等重跑的锚点', + `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_event_source` (`family_id`, `source_ref`), + KEY `idx_event_family_kind` (`family_id`, `kind`, `starts_at`), + CONSTRAINT `fk_event_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_event_creator` FOREIGN KEY (`creator_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一事件:观影/旅行/任务…共同点是都有时间、有人参与、要上日历' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `movie_details` ( + `event_id` INT UNSIGNED NOT NULL, + `info` TEXT DEFAULT NULL COMMENT '简介(豆瓣抓取的剧情简介等)', + `poster_key` VARCHAR(255) DEFAULT NULL COMMENT '海报(存储相对键,如 posters/xxx.jpg)', + `ticket_key` VARCHAR(255) DEFAULT NULL COMMENT '票根照片(存储相对键)', + `rating` TINYINT DEFAULT NULL COMMENT '评分 0-10(.MovieLog 10 分制原样保留)', + `review` TEXT DEFAULT NULL COMMENT '影评正文', + `feelings` VARCHAR(255) DEFAULT NULL COMMENT '观后感受标签,JSON 数组(如 ["温暖","激动"])', + `cinema_place` VARCHAR(100) DEFAULT NULL COMMENT '影院所在商圈/位置', + `cinema_name` VARCHAR(100) DEFAULT NULL COMMENT '影院名(与 events.location 冗余存一份,详情页直接用)', + `cinema_address` VARCHAR(200) DEFAULT NULL COMMENT '影院地址', + PRIMARY KEY (`event_id`), + CONSTRAINT `fk_mdet_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='观影事件的特有数据(一对一卫星表)' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `event_attendees` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `event_id` INT UNSIGNED NOT NULL, + `person_id` INT UNSIGNED NOT NULL COMMENT '同行的人(persons.id)', + `position` INT NOT NULL DEFAULT 100 COMMENT '展示先后', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_att_event_person` (`event_id`, `person_id`), + KEY `idx_att_person` (`person_id`), + CONSTRAINT `fk_att_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_att_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 `event_photos` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `event_id` INT UNSIGNED NOT NULL, + `key` VARCHAR(255) NOT NULL COMMENT '照片(存储相对键)', + `position` INT NOT NULL DEFAULT 100 COMMENT '展示先后', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_ep_event` (`event_id`, `position`), + CONSTRAINT `fk_ep_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='事件的现场照片(可多张)' SQL, ]; } diff --git a/app/Storage/OssDriver.php b/app/Storage/OssDriver.php index 83982e5..945bc3f 100644 --- a/app/Storage/OssDriver.php +++ b/app/Storage/OssDriver.php @@ -262,6 +262,54 @@ final class OssDriver implements StorageDriver return ['ok' => true, 'error' => '']; } + /** + * 服务端拷贝(CopyObject)—— v8 迁移用:同一个桶里把 movielog/ 前缀的对象 + * 复制成 family/ 前缀,不走流量、不用下载重传。 + * + * @param string $srcObjectKey 源对象**完整键**(含前缀,如 movielog/uploads/posters/x.jpg) + * @param string $destRelKey 目标**相对键**(不含本站前缀,如 posters/x.jpg) + * + * ⚠️ 签名注意:所有随请求发送的 x-oss-* 头都必须按字典序拼进签名串 + * (copy-source 在 security-token 之前),漏一个就是 SignatureDoesNotMatch。 + * CopyObject 不需要 Content-Type(拷贝沿用源对象的元数据),签名串里留空。 + */ + public function copyObject(string $srcObjectKey, string $destRelKey): array + { + if ($err = $this->validateConfig()) { + return ['ok' => false, 'error' => $err]; + } + $cred = $this->credentials(); + if (empty($cred['ok'])) { + return ['ok' => false, 'error' => $cred['error']]; + } + $bucket = trim((string)$this->cfg['bucket']); + $srcKey = ltrim($srcObjectKey, '/'); + $destKey = $this->objectKey($destRelKey); + $date = gmdate('D, d M Y H:i:s \G\M\T'); + $copySource = '/' . $bucket . '/' . Http::encodeKey($srcKey); + + $canonical = "x-oss-copy-source:{$copySource}\n"; + if ((string)($cred['token'] ?? '') !== '') { + $canonical .= "x-oss-security-token:{$cred['token']}\n"; + } + $stringToSign = "PUT\n\n\n{$date}\n{$canonical}/{$bucket}/{$destKey}"; + $signature = base64_encode(hash_hmac('sha1', $stringToSign, $cred['secret'], true)); + + $h = [ + 'Date: ' . $date, + 'x-oss-copy-source: ' . $copySource, + 'Authorization: OSS ' . $cred['id'] . ':' . $signature, + ]; + if ((string)($cred['token'] ?? '') !== '') { + $h[] = 'x-oss-security-token: ' . $cred['token']; + } + $res = Http::send('PUT', $this->objectUrl($destKey), $h, null, 60); + if (!$res['ok']) { + return ['ok' => false, 'error' => 'OSS CopyObject 失败:' . Http::shortError($res)]; + } + return ['ok' => true, 'error' => '']; + } + public function delete(string $key): array { $cred = $this->credentials(); diff --git a/app/Upload.php b/app/Upload.php index b746773..0b3029c 100644 --- a/app/Upload.php +++ b/app/Upload.php @@ -102,20 +102,34 @@ final class Upload $w = imagesx($src); $h = imagesy($src); - // 先按目标比例、在「用户挑的区域」里定位 —— 区域非法时退回整图居中 - $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' => '裁剪区域太小了,请把框放大一点']; + $free = ImageSlot::ratio($slot) === 0.0; // free 槽位(票根/照片):不裁剪,整图等比缩 + if ($free) { + // 用户挑的裁剪框对 free 槽位没有意义 —— 忽略,用整张图。 + // (票根是随手拍,比例五花八门,硬裁只会把票面裁掉一角。) + $sx = 0; $sy = 0; $sw = $w; $sh = $h; + } else { + // 先按目标比例、在「用户挑的区域」里定位 —— 区域非法时退回整图居中 + $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']; + if ($free) { + // 等比缩到槽位的 w×h **框内**(只缩不放大),保持原始比例 + $scale = min((int)$slot['w'] / $sw, (int)$slot['h'] / $sh, 1.0); + $outW = max(1, (int)round($sw * $scale)); + $outH = max(1, (int)round($sh * $scale)); + } else { + $outW = (int)$slot['w']; + $outH = (int)$slot['h']; + } $canvas = imagecreatetruecolor($outW, $outH); // PNG/GIF 的透明区转成纸白,不然头像会是黑底 $bg = imagecolorallocate($canvas, 253, 251, 245); diff --git a/assets/css/app.css b/assets/css/app.css index 2260069..96f6e00 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -758,3 +758,74 @@ textarea.input { min-height: 76px; resize: vertical; } .ct-row { flex-direction: column; gap: 6px; } .ct-row .ct-label { flex: none; } } + +/* ===== 观影(v8,MovieLog 迁入) ===== */ +.movie-list { display: flex; flex-direction: column; gap: 10px; } +.movie-card { + display: flex; gap: 14px; align-items: center; + padding: 10px; background: var(--card-bg, #fff); + border: 1px solid var(--line); border-radius: 12px; + text-decoration: none; color: var(--ink); +} +.movie-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-l); text-decoration: none; } +.m-poster { + flex: 0 0 64px; width: 64px; height: 96px; object-fit: cover; + border-radius: 8px; background: var(--line-2); display: block; +} +.m-poster-empty { + display: flex; align-items: center; justify-content: center; + color: var(--faint, #B9AE9F); font-size: 20px; font-weight: 600; +} +.m-main { min-width: 0; display: flex; flex-direction: column; gap: 3px; } +.m-title { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.rating { + background: var(--brand); color: #fff; border-radius: 999px; + padding: 1px 8px; font-size: 12px; font-weight: 600; + font-variant-numeric: tabular-nums; +} +.rating-lg { font-size: 14px; padding: 2px 10px; } +.m-meta { color: var(--muted); font-size: 13px; } +.m-who { color: var(--muted); font-size: 12px; } + +.movie-detail { display: grid; grid-template-columns: 220px 1fr; gap: 24px; align-items: start; } +.m-poster-col { display: flex; flex-direction: column; gap: 12px; } +.m-poster-lg { + width: 100%; aspect-ratio: 2 / 3; object-fit: cover; + border-radius: 12px; background: var(--line-2); display: flex; + align-items: center; justify-content: center; + color: var(--faint, #B9AE9F); font-size: 40px; font-weight: 600; +} +a .m-poster-lg { display: block; } +.m-ticket { width: 100%; border-radius: 10px; border: 1px solid var(--line); display: block; } +.m-info .m-score { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; } +.badge-feel { background: #F6E3D7; color: var(--brand-ink); } +.m-block { margin-top: 16px; } +.m-block h3 { font-size: 14px; margin: 0 0 6px; color: var(--muted); font-weight: 500; } +.m-block p { margin: 0; font-size: 14px; line-height: 1.7; white-space: normal; } + +.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; } +.photo-cell { position: relative; border-radius: 10px; overflow: hidden; border: 1px solid var(--line); } +.photo-cell img { width: 100%; height: 160px; object-fit: cover; display: block; } +.photo-del { + position: absolute; top: 6px; right: 6px; + width: 24px; height: 24px; border-radius: 50%; border: 0; + background: rgba(0,0,0,.55); color: #fff; font-size: 16px; line-height: 1; + cursor: pointer; padding: 0; +} +.photo-del:hover { background: var(--red, #C0533F); } +.photo-add label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 8px; } +.photo-add-row { display: flex; gap: 10px; align-items: center; } +.photo-add-row .input { flex: 1; } + +.feel-grid { display: flex; flex-wrap: wrap; gap: 8px 16px; } +.feel-item { display: flex; align-items: center; gap: 5px; font-size: 14px; cursor: pointer; } +.feel-item input { accent-color: var(--brand); } + +@media (max-width: 720px) { + .movie-detail { grid-template-columns: 1fr; } + .m-poster-col { flex-direction: row; } + .m-poster-col .m-poster-lg, .m-poster-col a:first-child { flex: 0 0 140px; width: 140px; } + .m-ticket { width: auto; max-width: 55%; } + .photo-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); } + .photo-cell img { height: 120px; } +} diff --git a/index.php b/index.php index 5e3cc5c..38422e6 100644 --- a/index.php +++ b/index.php @@ -17,6 +17,7 @@ use App\Crypto; use App\Database; use App\Family; use App\Kinship; +use App\Movie; use App\Nickname; use App\Person; use App\PersonGroup; @@ -1329,6 +1330,228 @@ switch ($r) { redirect('settings'); break; + // ==================== 观影(v8) ==================== + // + // MovieLog 整体迁入后的新家。数据落在统一事件原语上: + // events(骨架)+ movie_details(卫星)+ event_attendees + event_photos, + // 见 App\Movie。可见性与名册同一口径:能登录就能看,能写才能改; + // 迁进来的历史记录 source_ref = 'movielog:movie:'(重跑迁移的幂等锚点)。 + + /** 观影时间线 —— 按年分组倒序。 */ + case 'movies': + $familyId = (int)Auth::familyId(); + $rows = Movie::all($familyId); + $years = []; + foreach ($rows as $r) { + $y = $r['starts_at'] !== null ? (int)substr((string)$r['starts_at'], 0, 4) : 0; + $years[$y][] = $r; + } + unset($r); + krsort($years); + View::render('movies/index', [ + 'title' => '观影记录', + 'years' => $years, + 'total' => count($rows), + ], 'movies'); + break; + + case 'movies/show': + $familyId = (int)Auth::familyId(); + $m = Movie::find($familyId, (int)($_GET['id'] ?? 0)); + if (!$m) { + flash('err', '找不到这条观影记录。'); + redirect('movies'); + } + View::render('movies/show', [ + 'title' => (string)$m['title'], + 'm' => $m, + ], 'movies'); + break; + + case 'movies/new': + Auth::requireWrite(); + View::render('movies/form', [ + 'title' => '记一场电影', + 'm' => null, + 'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''), + 'errors' => [], + ], 'movies'); + break; + + case 'movies/edit': + Auth::requireWrite(); + $familyId = (int)Auth::familyId(); + $m = Movie::find($familyId, (int)($_GET['id'] ?? 0)); + if (!$m) { + flash('err', '找不到这条观影记录。'); + redirect('movies'); + } + View::render('movies/form', [ + 'title' => '编辑观影记录', + 'm' => $m, + 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), + 'errors' => [], + ], 'movies'); + break; + + /** 收集表单里和观影有关的字段(create / update 共用一份)。 */ + case 'movies/create': + case 'movies/update': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + + $isEdit = $r === 'movies/update'; + $id = $isEdit ? (int)($_POST['id'] ?? 0) : 0; + $m = $isEdit ? Movie::find($familyId, $id) : null; + if ($isEdit && !$m) { + flash('err', '找不到这条观影记录。'); + redirect('movies'); + } + + $d = [ + 'title' => trim((string)($_POST['title'] ?? '')), + 'starts_at' => trim((string)($_POST['starts_at'] ?? '')), + 'location' => trim((string)($_POST['location'] ?? '')), + 'note' => trim((string)($_POST['note'] ?? '')), + 'info' => trim((string)($_POST['info'] ?? '')), + 'rating' => trim((string)($_POST['rating'] ?? '')), + 'review' => trim((string)($_POST['review'] ?? '')), + 'feelings' => array_values((array)($_POST['feelings'] ?? [])), + 'cinema_place' => trim((string)($_POST['cinema_place'] ?? '')), + 'cinema_name' => trim((string)($_POST['cinema_name'] ?? '')), + 'cinema_address' => trim((string)($_POST['cinema_address'] ?? '')), + // 同行的人:只认「确实存在且属于这个家」的 id —— 表单可以改, + // 不能凭它把别人家的成员挂进自己家的观影记录里(会顺着详情页漏人名)。 + 'attendees' => array_map('intval', (array)($_POST['attendees'] ?? [])), + ]; + $_familyPeople = Person::map($familyId); + $d['attendees'] = array_values(array_filter($d['attendees'], + static fn($pid) => isset($_familyPeople[$pid]))); + $errors = $d['title'] === '' ? ['title' => '片名不能为空'] : []; + + // ⚠️ 图片上传失败**不挡保存**(与头像同一思路:一条记录因为一张 5MB 原图 + // 存不进去,看起来像功能坏了)。现在是先存记录,图片的事单独提示。 + // 海报 / 票根:free 槽位,不需要裁剪框;编辑时换新图就顺带删掉旧对象。 + $warns = []; + foreach (['poster', 'ticket'] as $slot) { + $up = Upload::image($familyId, $_FILES[$slot] ?? [], $slot); + if ($up['ok']) { + $oldKey = $isEdit ? (string)($m[$slot === 'poster' ? 'poster_key' : 'ticket_key'] ?? '') : ''; + $d[$slot === 'poster' ? 'poster_key' : 'ticket_key'] = (string)$up['file']; + if ($oldKey !== '' && $oldKey !== (string)$up['file']) { + \App\Storage\Storage::delete($familyId, $oldKey); + } + } elseif ((string)($up['error'] ?? '') !== '') { + $warns[] = ($slot === 'poster' ? '海报' : '票根') . '没有保存:' . $up['error']; + } elseif ($isEdit) { + // 没传新图 → 保留库里的旧值 + $d[$slot === 'poster' ? 'poster_key' : 'ticket_key'] = (string)($m[$slot === 'poster' ? 'poster_key' : 'ticket_key'] ?? ''); + } + } + // 现场照片(多选,仅新增表单提供;编辑页在详情页里逐张补/删) + if (!$isEdit) { + $photoFiles = []; + if (!empty($_FILES['photo']) && is_array($_FILES['photo']['name'])) { + foreach ($_FILES['photo']['name'] as $i => $_n) { + $photoFiles[] = [ + 'name' => $_FILES['photo']['name'][$i], + 'type' => $_FILES['photo']['type'][$i], + 'tmp_name' => $_FILES['photo']['tmp_name'][$i], + 'error' => $_FILES['photo']['error'][$i], + 'size' => $_FILES['photo']['size'][$i], + ]; + } + } + $photoKeys = []; + foreach ($photoFiles as $f) { + $up = Upload::image($familyId, $f, 'photo'); + if ($up['ok']) { + $photoKeys[] = (string)$up['file']; + } elseif ((string)($up['error'] ?? '') !== '') { + $warns[] = '有一张照片没有保存:' . $up['error']; + } + } + $d['photos'] = $photoKeys; + } + + if ($errors) { + set_old($_POST); + View::render('movies/form', [ + 'title' => $isEdit ? '编辑观影记录' : '记一场电影', + 'm' => $m, + 'people' => Person::roster($familyId, $me, 'member', ''), + 'errors' => $errors, + ], 'movies'); + exit; + } + + if ($isEdit) { + Movie::update($familyId, $id, $d); + clear_old(); + flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); + redirect('movies/show', ['id' => $id]); + } else { + $newId = Movie::create($familyId, $me, $d); + clear_old(); + flash('ok', '已记下这场电影。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); + redirect('movies/show', ['id' => $newId]); + } + break; + + /** 编辑页补传一张现场照片(详情页里的小表单,一次一张)。 */ + case 'movies/photo_add': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + if (!Movie::find($familyId, $id)) { + flash('err', '找不到这条观影记录。'); + redirect('movies'); + } + $up = Upload::image($familyId, $_FILES['photo'] ?? [], 'photo'); + if ($up['ok']) { + // savePhotos 是整组替换 —— 现有键取出来再追加一张,位置接着排 + $keys = array_map('strval', array_column(Movie::photos($id), 'key')); + $keys[] = (string)$up['file']; + Movie::savePhotos($id, $keys); + flash('ok', '照片已加上。'); + } else { + flash('err', '照片没有保存:' . (string)($up['error'] ?: '没选文件')); + } + redirect('movies/show', ['id' => $id]); + break; + + case 'movies/photo_del': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $pid = (int)($_POST['photo_id'] ?? 0); + if (!Movie::find($familyId, $id)) { + flash('err', '找不到这条观影记录。'); + redirect('movies'); + } + Movie::deletePhoto($familyId, $id, $pid); + flash('ok', '照片已删除。'); + redirect('movies/show', ['id' => $id]); + break; + + case 'movies/destroy': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + if (!Movie::find($familyId, $id)) { + flash('err', '找不到这条观影记录。'); + redirect('movies'); + } + Movie::destroy($familyId, $id); + flash('ok', '这条观影记录已删除。'); + redirect('movies'); + break; + default: http_response_code(404); View::render('error', [ diff --git a/templates/home.php b/templates/home.php index 9630e9d..5ca156d 100644 --- a/templates/home.php +++ b/templates/home.php @@ -6,10 +6,8 @@ * 爱人登进来看到的是同一批人,称呼却变成「公公/婆婆」。 * 称呼全部由 Kinship 现算、由 Nickname 决定"我叫他什么",库里不存任何一次性称谓。 * - * 标题下面那一行小字的取法(这处被明确纠正过): - * 先看**我自己**有没有写 slogan → 没写才回落到家庭统一的那句。 - * 所以同一页不同人登进来看到的是不同的话,而不是全家共用一个口号。 - * 家庭名称本身不再展示 —— 标题已经是「某某某的家书」,再挂一个家庭名只会打架。 + * ⚠️ 标题下的那句个人口号已按用户要求撤掉(v8): + * hero 区只留标题与三个统计,不再读 slogan / motto。 */ $kinCount = count($kin); $hasRel = $relCount > 0; @@ -30,16 +28,6 @@ if (!$list) { } } -// 标题下的一行小字:我的一句话 → 家庭统一那句 → 兜底 -$motto = trim((string)$mySlogan); -$mottoIsMine = $motto !== ''; -if (!$mottoIsMine) { - $motto = trim((string)($family['motto'] ?? '')); -} -if ($motto === '') { - $motto = '一本用岁月写成的家谱'; -} - // 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出 $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '农历生日']; ?> @@ -47,12 +35,6 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '

-

- - - 我的一句话 - -

与我有关
diff --git a/templates/kin/tree.php b/templates/kin/tree.php index b0db119..86505b9 100644 --- a/templates/kin/tree.php +++ b/templates/kin/tree.php @@ -61,9 +61,4 @@ $genWord = function (int $level): string {
- - \ No newline at end of file diff --git a/templates/layout.php b/templates/layout.php index 8f68f01..f389eb8 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -1,10 +1,12 @@ ['首页', 'home'], 'kin' => ['家谱', 'kin'], 'people' => ['名册', 'people'], + 'movies' => ['观影', 'movies'], ]; // ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的, // 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。 diff --git a/templates/movies/form.php b/templates/movies/form.php new file mode 100644 index 0000000..fda2bc3 --- /dev/null +++ b/templates/movies/form.php @@ -0,0 +1,157 @@ + +
+

+ +
+ +
+ + + + + +
+
+
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + + +
+ MovieLog 里记的英文标签(warm / excited…)搬过来时已经翻成中文。 +
+ +
+ + +
+ + + +
+ + 名册里还没有人,先把家人加进名册再来记。 + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + 已有海报,不选新图就保留原图。 + +
+ +
+ + + + 已有票根,不选新图就保留原图。 + +
+ + +
+ + + 之后也可以在详情页里逐张补传。 +
+ +
+ +
+ + 取消 +
+
+
diff --git a/templates/movies/index.php b/templates/movies/index.php new file mode 100644 index 0000000..25ba6b7 --- /dev/null +++ b/templates/movies/index.php @@ -0,0 +1,61 @@ + +
+

观影记录

+ 一共 场 +
+ + 记一场电影 + +
+
+ + +
+ 还没有观影记录 + 一家人一起看过的电影,都会按年份排在这里。 +
+ + $list): ?> +
+

0 ? (int)$y : '日期待补' ?> 场

+ +
+ + diff --git a/templates/movies/show.php b/templates/movies/show.php new file mode 100644 index 0000000..f0a63e6 --- /dev/null +++ b/templates/movies/show.php @@ -0,0 +1,134 @@ + +
+

+ +
+ + 编辑 +
+ + + +
+ + 返回 +
+
+ +
+
+ + + 海报 + + + + + + + 票根 + + +
+ +
+ +
+ + + + + + +
+ + +
+
观影日期
+ +
影院
+ $s !== '')))) ?> +
+ + +
地点
+ +
同行的人
+ + $a): ?> 0 ? '、' : '' ?> + 一个人看的 +
+
+ + +

简介

+ + +

观后感

+ + +

备注

+ +
+
+ + +
+

现场照片 张

+
+ +
+ + + + +
+ + + + +
+ +
+ +
+
+ + + +
+
+ + + +
+ + +
+
+
+