diff --git a/app/Cinema.php b/app/Cinema.php new file mode 100644 index 0000000..baaa712 --- /dev/null +++ b/app/Cinema.php @@ -0,0 +1,110 @@ + 按地方、名称排序的全部影院 */ + public static function all(int $familyId): array + { + $st = Database::get()->prepare( + 'SELECT c.*, (SELECT COUNT(*) FROM movie_details d WHERE d.cinema_id = c.id) AS movie_count + FROM cinemas c WHERE c.family_id = ? ORDER BY c.place, c.name' + ); + $st->execute([$familyId]); + return $st->fetchAll(); + } + + public static function find(int $familyId, int $id): ?array + { + $st = Database::get()->prepare( + 'SELECT * FROM cinemas WHERE id = ? AND family_id = ?' + ); + $st->execute([$id, $familyId]); + return $st->fetch() ?: null; + } + + /** + * 新建/更新影院。$place/$name/$address 已由调用方裁剪; + * 返回 ''(成功)或错误文案。同名(含同地方)判重靠 uk_cinema。 + */ + public static function save(int $familyId, int $id, string $place, string $name, + ?string $address, string $color): string + { + if ($name === '') { + return '影院名称不能为空'; + } + if (mb_strlen($name, 'UTF-8') > 100) { + return '影院名称太长了'; + } + $place = mb_substr($place, 0, 100, 'UTF-8'); + $address = $address !== null ? mb_substr($address, 0, 200, 'UTF-8') : null; + $db = Database::get(); + if ($id > 0) { + $st = $db->prepare( + 'SELECT COUNT(*) FROM cinemas + WHERE family_id = ? AND place = ? AND name = ? AND id <> ?' + ); + $st->execute([$familyId, $place, $name, $id]); + if ((int)$st->fetchColumn() > 0) { + return '已经有同地方同名的影院了'; + } + $db->prepare('UPDATE cinemas SET place = ?, name = ?, address = ?, color = ? WHERE id = ? AND family_id = ?') + ->execute([$place, $name, $address, $color, $id, $familyId]); + } else { + $st = $db->prepare( + 'SELECT COUNT(*) FROM cinemas WHERE family_id = ? AND place = ? AND name = ?' + ); + $st->execute([$familyId, $place, $name]); + if ((int)$st->fetchColumn() > 0) { + return '已经有同地方同名的影院了'; + } + $db->prepare('INSERT INTO cinemas (family_id, place, name, address, color) VALUES (?, ?, ?, ?, ?)') + ->execute([$familyId, $place, $name, $address, $color]); + } + return ''; + } + + /** 删除影院;关联的观影记录 cinema_id 置空(外键级联),历史记录本身保留 */ + public static function destroy(int $familyId, int $id): void + { + Database::get()->prepare('DELETE FROM cinemas WHERE id = ? AND family_id = ?') + ->execute([$id, $familyId]); + } + + /** 把表单提交的颜色规范化成 #rrggbb(非法值回默认灰) */ + public static function normalizeColor(string $raw): string + { + $raw = trim($raw); + if (preg_match('/^#([0-9a-fA-F]{6})$/', $raw, $m)) { + return '#' . strtolower($m[1]); + } + if (preg_match('/^([0-9a-fA-F]{6})$/', $raw, $m)) { + return '#' . strtolower($m[1]); + } + return '#888888'; + } +} diff --git a/app/Movie.php b/app/Movie.php index c62223e..d4d1e85 100644 --- a/app/Movie.php +++ b/app/Movie.php @@ -3,16 +3,18 @@ * 观影记录(统一事件原语的第一个落地种类)。 * * 归一模型(见 familybook.md 的核心设计): - * events = 一切记录的共同骨架(标题 / 日期 / 地点 / 来源引用) - * movie_details = 观影特有的卫星表(海报 / 票根 / 评分 / 影评 / 影院三段) + * events = 一切记录的共同骨架(标题 / 起止时刻 / 地点 / 来源引用) + * movie_details = 观影特有的卫星表(海报 / 票根 / 关联影院) * attendees = 同行观影的人(persons.id) * photos = 现场照片(可多张) + * reviews = 评价与讨论(movie_reviews / movie_review_comments,见 MovieReview) + * cinemas = 影院独立管理(见 Cinema) * * 图片一律存**相对键**(如 posters/xxx.jpg),地址由 media.php 代理 —— * 本地与 OSS 的差别被 Storage 吃掉,这里永远不碰完整 URL。 * * source_ref 是迁移幂等的锚点:MovieLog 整体迁入时写成 'movielog:movie:', - * 重跑迁移按它先删后插,不会出现一半新一半旧的中间态。 + * 重跑迁移按它先查再插,不会出现一半新一半旧的中间态。 */ declare(strict_types=1); @@ -21,49 +23,48 @@ 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 + /** @return array 观影记录,按开场时刻倒序(瀑布流用)。 + * $keyword 非空时搜:片名 / 影院名 / 同行人名(LIKE)。 */ + public static function all(int $familyId, string $keyword = ''): 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, + $sql = + 'SELECT e.id, e.title, e.starts_at, e.ends_at, e.note, + d.poster_key, c.name AS cinema_name, c.color AS cinema_color, (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(); + LEFT JOIN cinemas c ON c.id = d.cinema_id + WHERE e.family_id = ? AND e.kind = "movie"'; + $bind = [$familyId]; + $kw = trim($keyword); + if ($kw !== '') { + $sql .= ' AND (e.title LIKE ? OR c.name LIKE ? OR e.location LIKE ? + OR EXISTS (SELECT 1 FROM event_attendees a JOIN persons p ON p.id = a.person_id + WHERE a.event_id = e.id AND p.name LIKE ?))'; + $like = '%' . $kw . '%'; + array_push($bind, $like, $like, $like, $like); + } + $sql .= ' ORDER BY e.starts_at DESC, e.id DESC'; + $st = Database::get()->prepare($sql); + $st->execute($bind); + $rows = $st->fetchAll(); + MovieReview::attachStats($rows); + return $rows; } - /** 一条完整记录(详情/编辑页用):事件 + 卫星 + 同行人 + 照片 */ + /** 一条完整记录(详情/编辑页用):事件 + 卫星 + 影院 + 同行人 + 照片 */ 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 + 'SELECT e.*, d.info, d.poster_key, d.ticket_key, d.cinema_id, + c.place AS cinema_place, c.name AS cinema_name, + c.address AS cinema_address, c.color AS cinema_color FROM events e LEFT JOIN movie_details d ON d.event_id = e.id + LEFT JOIN cinemas c ON c.id = d.cinema_id WHERE e.family_id = ? AND e.id = ? AND e.kind = "movie"' ); $st->execute([$familyId, $id]); @@ -100,18 +101,18 @@ final class Movie /** * 新建一条观影记录,返回事件 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[]) + * $d 字段:title, starts_at(Y-m-d H:i:s), ends_at, location, note, info, + * cinema_id(int|null), 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", ?, ?, ?, ?, ?)' + 'INSERT INTO events (family_id, creator_id, kind, title, starts_at, ends_at, location, note, source_ref) + VALUES (?, ?, "movie", ?, ?, ?, ?, ?, ?)' )->execute([ - $familyId, $creatorId, (string)$d['title'], self::dateOrNull($d['starts_at'] ?? ''), + $familyId, $creatorId, (string)$d['title'], + self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''), self::locOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''), $d['source_ref'] ?? null, ]); @@ -126,9 +127,11 @@ final class Movie { $db = Database::get(); $db->prepare( - 'UPDATE events SET title = ?, starts_at = ?, location = ?, note = ? WHERE id = ? AND family_id = ?' + 'UPDATE events SET title = ?, starts_at = ?, ends_at = ?, location = ?, note = ? + WHERE id = ? AND family_id = ?' )->execute([ - (string)$d['title'], self::dateOrNull($d['starts_at'] ?? ''), + (string)$d['title'], + self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''), self::locOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''), $id, $familyId, ]); @@ -140,35 +143,22 @@ final class Movie /** 卫星表 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 = [ + $db = Database::get(); + $cid = (int)($d['cinema_id'] ?? 0); + $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'] ?? ''), + $cid > 0 ? $cid : null, ]; $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]); + $db->prepare('UPDATE movie_details SET info=?, poster_key=?, ticket_key=?, cinema_id=? 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]); + $db->prepare('INSERT INTO movie_details (info, poster_key, ticket_key, cinema_id, event_id) VALUES (?,?,?,?,?)') + ->execute([...$row, $eventId]); } } @@ -246,12 +236,49 @@ final class Movie } } + // ---------------- 展示格式化 ---------------- + + /** + * 观影时刻的人话展示: + * 只有日期(时间恰好 00:00:00 且无散场)→「2026年09月30日」; + * 有开场时刻 →「2026年09月30日 19:30:00」;有散场 → 追加「 – 21:45:12」。 + * 两个都空 →「日期待补」。 + */ + public static function formatWatch(?string $start, ?string $end): string + { + if ($start === null || $start === '') { + return '日期待补'; + } + $ts = strtotime($start); + if ($ts === false) { + return '日期待补'; + } + $out = date('Y年m月d日', $ts); + $midnight = substr((string)$start, 11) === '00:00:00'; + if (!$midnight) { + $out .= ' ' . date('H:i:s', $ts); + } + if ($end !== null && $end !== '') { + $ets = strtotime($end); + if ($ets !== false) { + if (date('Y-m-d', $ets) !== date('Y-m-d', $ts)) { + $out = date('Y年m月d日', $ts) . ($midnight ? '' : ' ' . date('H:i:s', $ts)) + . ' – ' . date('Y年m月d日 H:i:s', $ets); + } else { + $out .= ' – ' . date('H:i:s', $ets); + } + } + } + return $out; + } + // ---------------- 小工具 ---------------- - private static function dateOrNull(string $v): ?string + /** 'Y-m-d H:i:s' 校验(秒级精确);空串/不合法回 NULL */ + private static function dtOrNull(string $v): ?string { $v = trim($v); - if ($v === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) { + if ($v === '' || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $v)) { return null; } return $v; diff --git a/app/MovieReview.php b/app/MovieReview.php new file mode 100644 index 0000000..da31ceb --- /dev/null +++ b/app/MovieReview.php @@ -0,0 +1,398 @@ + [emoji, 中文标签] */ + public const CATALOG = [ + 'happy' => ['emoji' => '😄', 'label' => '开心'], + 'moved' => ['emoji' => '💖', 'label' => '感动'], + 'warm' => ['emoji' => '🥰', 'label' => '温暖'], + 'excited' => ['emoji' => '🤩', 'label' => '激动'], + 'thoughtful' => ['emoji' => '🤔', 'label' => '深思'], + 'tense' => ['emoji' => '😰', 'label' => '紧张'], + 'scared' => ['emoji' => '😨', 'label' => '恐惧'], + 'surprised' => ['emoji' => '😲', 'label' => '震惊'], + 'sad' => ['emoji' => '😢', 'label' => '难受'], + 'angry' => ['emoji' => '😠', 'label' => '气愤'], + 'bored' => ['emoji' => '😴', 'label' => '无聊'], + 'confused' => ['emoji' => '😵', 'label' => '困惑'], + ]; + + /** v8 时代 movie_details.feelings 存的中文标签 → 目录 key(迁移用) */ + private const LABEL_TO_KEY = [ + '温暖' => 'warm', '激动' => 'excited', '欢乐' => 'happy', + '深思' => 'thoughtful', '伤感' => 'sad', '震撼' => 'surprised', + '治愈' => 'moved', '无聊' => 'bored', '开心' => 'happy', + '感动' => 'moved', '紧张' => 'tense', '恐惧' => 'scared', + '震惊' => 'surprised', '难受' => 'sad', '气愤' => 'angry', + '困惑' => 'confused', + ]; + + /** @return array 1-10 分各给一句词(打分实时提示 / title 用) */ + public static function ratingWords(): array + { + return array_map(static fn($r) => self::ratingWord($r), range(1, 10)); + } + + public static function ratingWord(int $rating): string + { + return match (true) { + $rating <= 2 => '很失望', + $rating <= 4 => '不太行', + $rating <= 6 => '还行', + $rating <= 8 => '不错', + default => '很棒', + }; + } + + /** 把表单提交(数组)或库里取出的数组整理成合法 key 列表:过滤、去重、截断、按目录顺序 */ + public static function normalizeFeelings(array $raw): array + { + $picked = []; + foreach ($raw as $key) { + $key = is_string($key) ? trim($key) : ''; + if ($key !== '' && isset(self::CATALOG[$key]) && !in_array($key, $picked, true)) { + $picked[] = $key; + } + } + $ordered = []; + foreach (array_keys(self::CATALOG) as $key) { + if (in_array($key, $picked, true)) { + $ordered[] = $key; + } + } + return array_slice($ordered, 0, self::FEELING_MAX); + } + + /** 库里 feelings JSON → 合法 key 列表 */ + public static function parseFeelings(?string $json): array + { + if ($json === null || $json === '') { + return []; + } + $keys = json_decode($json, true); + return is_array($keys) ? self::normalizeFeelings($keys) : []; + } + + public static function feelingsToJson(array $keys): string + { + return json_encode(array_values($keys), JSON_UNESCAPED_UNICODE); + } + + /** v8 迁移用:旧中文标签 JSON → 新 key JSON(认不出的丢掉) */ + public static function labelsToKeysJson(string $json): ?string + { + if (trim($json) === '') { + return null; + } + $labels = json_decode($json, true); + if (!is_array($labels)) { + return null; + } + $keys = []; + foreach ($labels as $label) { + $k = self::LABEL_TO_KEY[trim((string)$label)] ?? null; + if ($k !== null && !in_array($k, $keys, true)) { + $keys[] = $k; + } + } + return $keys ? self::feelingsToJson(self::normalizeFeelings($keys)) : null; + } + + // ---------------- 读取 ---------------- + + /** 某部电影的全部评价(新在前),讨论按两级结构挂好 */ + public static function forEvent(int $eventId): array + { + $st = Database::get()->prepare( + 'SELECT r.*, p.name AS person_name, p.avatar AS person_avatar + FROM movie_reviews r JOIN persons p ON p.id = r.person_id + WHERE r.event_id = ? + ORDER BY r.created_at DESC, r.id DESC' + ); + $st->execute([$eventId]); + $reviews = $st->fetchAll(); + foreach ($reviews as &$rv) { + $rv['feelings'] = self::parseFeelings($rv['feelings']); + } + unset($rv); + self::attachComments($reviews); + return $reviews; + } + + /** 给一批评价挂上 comments(一次查询);两级平铺:回复全部归到所属顶层评论下 */ + private static function attachComments(array &$reviews): void + { + if (!$reviews) { + return; + } + $ids = array_values(array_filter(array_column($reviews, 'id'), static fn($v) => (int)$v > 0)); + if (!$ids) { + return; + } + $ph = implode(',', array_fill(0, count($ids), '?')); + $st = Database::get()->prepare( + "SELECT c.*, p.name AS person_name, p.avatar AS person_avatar, + rp.name AS reply_to_name + FROM movie_review_comments c + JOIN persons p ON p.id = c.person_id + LEFT JOIN persons rp ON rp.id = c.reply_to_person_id + WHERE c.review_id IN ($ph) + ORDER BY c.created_at ASC, c.id ASC" + ); + $st->execute($ids); + + $byId = []; + foreach ($st->fetchAll() as $row) { + $row['feelings'] = self::parseFeelings($row['feelings']); + $row['parent_id'] = ($row['parent_id'] === null || (int)$row['parent_id'] <= 0) + ? null : (int)$row['parent_id']; + $byId[(int)$row['id']] = $row; + } + + // 顶层评论按评价分桶(保持时间顺序) + $grouped = []; // review_id => [ ['comment'=>…, 'replies'=>[…]], … ] + $index = []; // review_id => [顶层评论 id => 下标] + foreach ($byId as $row) { + if ($row['parent_id'] !== null) { + continue; + } + $rvId = (int)$row['review_id']; + $grouped[$rvId][] = ['comment' => $row, 'replies' => []]; + $index[$rvId][(int)$row['id']] = count($grouped[$rvId]) - 1; + } + // 挂回复:沿 parent 链向上找所属顶层评论(容忍手工改坏的数据,最多 20 步防成环) + foreach ($byId as $row) { + if ($row['parent_id'] === null) { + continue; + } + $rvId = (int)$row['review_id']; + $root = (int)$row['parent_id']; + for ($i = 0; $i < 20 && isset($byId[$root]) && $byId[$root]['parent_id'] !== null; $i++) { + $root = (int)$byId[$root]['parent_id']; + } + if (isset($index[$rvId][$root])) { + $grouped[$rvId][$index[$rvId][$root]]['replies'][] = $row; + } + // 所属顶层已被删的回复直接丢掉,不让它变成挂在评价上的孤儿 + } + + foreach ($reviews as &$rv) { + $rv['comments'] = $grouped[(int)$rv['id']] ?? []; + } + unset($rv); + } + + /** 评分概况:['count' => n, 'avg' => 7.5];没人评价时 avg 为 null(展示别显示 0 分) */ + public static function stats(int $eventId): array + { + $st = Database::get()->prepare( + 'SELECT COUNT(*) AS c, AVG(rating) AS a FROM movie_reviews WHERE event_id = ?' + ); + $st->execute([$eventId]); + $row = $st->fetch(); + $n = (int)($row['c'] ?? 0); + return ['count' => $n, 'avg' => $n > 0 ? round((float)$row['a'], 1) : null]; + } + + /** 给瀑布流的行批量挂上评分概况(一次查询) */ + public static function attachStats(array &$rows): void + { + if (!$rows) { + return; + } + $ids = array_values(array_unique(array_filter( + array_column($rows, 'id'), + static fn($v) => (int)$v > 0 + ))); + if (!$ids) { + return; + } + $ph = implode(',', array_fill(0, count($ids), '?')); + $st = Database::get()->prepare( + "SELECT event_id, COUNT(*) AS c, AVG(rating) AS a + FROM movie_reviews WHERE event_id IN ($ph) GROUP BY event_id" + ); + $st->execute($ids); + $map = []; + foreach ($st->fetchAll() as $row) { + $map[(int)$row['event_id']] = [ + 'count' => (int)$row['c'], + 'avg' => round((float)$row['a'], 1), + ]; + } + foreach ($rows as &$r) { + $r['review_count'] = $map[(int)$r['id']]['count'] ?? 0; + $r['review_avg'] = $map[(int)$r['id']]['avg'] ?? null; + } + unset($r); + } + + /** 当前账号在这部电影上的评价(没有则 null) */ + public static function mine(int $eventId, int $personId): ?array + { + if ($personId <= 0) { + return null; + } + $st = Database::get()->prepare( + 'SELECT * FROM movie_reviews WHERE event_id = ? AND person_id = ?' + ); + $st->execute([$eventId, $personId]); + $row = $st->fetch(); + if ($row) { + $row['feelings'] = self::parseFeelings($row['feelings']); + } + return $row ?: null; + } + + public static function reviewById(int $reviewId): ?array + { + $st = Database::get()->prepare('SELECT * FROM movie_reviews WHERE id = ?'); + $st->execute([$reviewId]); + return $st->fetch() ?: null; + } + + public static function commentById(int $commentId): ?array + { + if ($commentId <= 0) { + return null; + } + $st = Database::get()->prepare('SELECT * FROM movie_review_comments WHERE id = ?'); + $st->execute([$commentId]); + return $st->fetch() ?: null; + } + + // ---------------- 写入 ---------------- + + /** + * 发表/修改评价:每人对每部电影只有一条,已有则更新。 + * 校验:rating 1-10、正文非空未超长。成功返回 true,失败返回错误文案。 + */ + public static function save(int $eventId, int $familyId, int $personId, int $rating, + string $content, array $feelings, bool $isUpdate): string + { + if ($rating < 1 || $rating > 10) { + return '请先选一个 1-10 的评分'; + } + $content = trim($content); + if ($content === '') { + return '评价内容不能为空'; + } + if (mb_strlen($content, 'UTF-8') > self::CONTENT_MAX) { + return '评价内容太长了(最多 ' . self::CONTENT_MAX . ' 字)'; + } + $feel = self::feelingsToJson(self::normalizeFeelings($feelings)); + $db = Database::get(); + if ($isUpdate) { + $db->prepare( + 'UPDATE movie_reviews SET rating = ?, content = ?, feelings = ? WHERE id = ?' + )->execute([$rating, $content, $feel, (int)self::mine($eventId, $personId)['id']]); + } else { + $db->prepare( + 'INSERT INTO movie_reviews (family_id, event_id, person_id, rating, content, feelings) + VALUES (?, ?, ?, ?, ?, ?)' + )->execute([$familyId, $eventId, $personId, $rating, $content, $feel]); + } + return ''; + } + + /** 删除自己的评价(下面的讨论随外键级联一起删)。只能删自己的。 */ + public static function deleteMine(int $reviewId, int $personId): bool + { + $st = Database::get()->prepare( + 'DELETE FROM movie_reviews WHERE id = ? AND person_id = ?' + ); + $st->execute([$reviewId, $personId]); + return $st->rowCount() > 0; + } + + /** + * 在一条评价下评论,或回复某条评论。parent_id=0 → 顶层;>0 → 回复那条。 + * 回复的回复规范化到同一条顶层评论之下(库里 parent_id 永远指向顶层)。 + * 成功返回新评论 id,失败返回错误文案。 + */ + public static function addComment(int $reviewId, int $personId, string $content, + array $feelings, int $parentId): int|string + { + $content = trim($content); + if ($content === '') { + return $parentId > 0 ? '回复内容不能为空' : '评论内容不能为空'; + } + if (mb_strlen($content, 'UTF-8') > self::COMMENT_MAX) { + return ($parentId > 0 ? '回复' : '评论') . '内容太长了(最多 ' . self::COMMENT_MAX . ' 字)'; + } + $review = self::reviewById($reviewId); + if (!$review) { + return '这条评价不存在'; + } + + $parent = null; + $replyTo = null; + if ($parentId > 0) { + $parent = self::commentById($parentId); + // 只能回复「同一条评价下」的评论,防止拿别的评价的 comment_id 跨过去 + if (!$parent || (int)$parent['review_id'] !== $reviewId) { + return '要回复的那条评论不存在'; + } + $replyTo = (int)$parent['person_id']; + if ($parent['parent_id'] !== null && (int)$parent['parent_id'] > 0) { + $parentId = (int)$parent['parent_id']; // 回复的回复挂到顶层 + } + } + + $db = Database::get(); + $db->prepare( + 'INSERT INTO movie_review_comments + (review_id, person_id, parent_id, reply_to_person_id, content, feelings) + VALUES (?, ?, ?, ?, ?, ?)' + )->execute([ + $reviewId, $personId, + $parentId > 0 ? $parentId : null, + $replyTo, + $content, + self::feelingsToJson(self::normalizeFeelings($feelings)), + ]); + return (int)$db->lastInsertId(); + } + + /** 删除自己的评论;删顶层时连回复一起删(回复只存在于顶层之下,留着就是孤儿) */ + public static function deleteComment(int $commentId, int $personId): bool + { + $db = Database::get(); + $st = $db->prepare( + 'DELETE FROM movie_review_comments WHERE id = ? AND person_id = ?' + ); + $st->execute([$commentId, $personId]); + if ($st->rowCount() <= 0) { + return false; + } + $db->prepare('DELETE FROM movie_review_comments WHERE parent_id = ?') + ->execute([$commentId]); + return true; + } +} diff --git a/app/Schema.php b/app/Schema.php index b91a510..a87d77b 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -42,8 +42,15 @@ final class Schema * 影院三段)+ event_attendees(参与人)+ event_photos(现场照片)。 * MovieLog 的 219 部观影记录整体迁入(source_ref='movielog:movie:', * 幂等重跑的锚点)。日历本/CalDAV 是后续阶段,events 先不带 calendar_id。 + * 9 → **观影对齐 MovieLog(用户五点反馈)**:① 列表改瀑布流海报墙; + * ② events.starts_at 升 DATETIME + 新增 ends_at(观影起止精确到秒, + * 复用 _datetime.php 控件);③ 新建 cinemas(所在地方/名称/地址/主题色), + * movie_details 只留 cinema_id(三段列迁移后删掉);④ 评分评论照搬 + * MovieLog 模型:movie_reviews(每账号每片一条,1-10 分 + 正文 + 感受) + * + movie_review_comments(两级平铺讨论),movie_details 旧的单条 + * rating/review/feelings 迁进 movie_reviews 后删列。 */ - public const VERSION = '8'; + public const VERSION = '9'; /** @return string[] */ public static function statements(): array @@ -206,7 +213,8 @@ CREATE TABLE IF NOT EXISTS `events` ( `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)', + `starts_at` DATETIME DEFAULT NULL COMMENT '发生时刻(观影=开场时间,精确到秒)', + `ends_at` DATETIME DEFAULT NULL COMMENT '结束时刻(观影=散场时间,精确到秒)', `location` VARCHAR(200) DEFAULT NULL COMMENT '地点(观影=影院名)', `note` TEXT DEFAULT NULL COMMENT '备注', `source_ref` VARCHAR(64) DEFAULT NULL COMMENT '外部来源引用(如 movielog:movie:123)——迁移幂等重跑的锚点', @@ -218,22 +226,77 @@ CREATE TABLE IF NOT EXISTS `events` ( 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, + // ⚠️ cinemas 必须排在 movie_details **之前**:后者的 fk_mdet_cinema + // 引用 cinemas.id,MariaDB 默认 FOREIGN_KEY_CHECKS=1, + // 顺序反了全新安装会在 movie_details 上报 1215、 + // 整个建表循环从那里断掉(线上老库 IF NOT EXISTS 跳过、炸不出来, + // 只有全新安装/测试库会 500 —— 2026-09-30 e2e 抓到)。 + <<<'SQL' +CREATE TABLE IF NOT EXISTS `cinemas` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `place` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '所在地方(城市 / 商圈)', + `name` VARCHAR(100) NOT NULL COMMENT '影院名称', + `address` VARCHAR(200) DEFAULT NULL COMMENT '详细地址', + `color` CHAR(7) NOT NULL DEFAULT '#888888' COMMENT '展示用主题色(列表/详情里影院名着色)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cinema` (`family_id`, `place`, `name`), + KEY `idx_cinema_family` (`family_id`), + CONSTRAINT `fk_cinema_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='影院(独立管理,MovieLog 同款:地方/名称/地址/主题色)' 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 '影院地址', + `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 '票根照片(存储相对键)', + `cinema_id` INT UNSIGNED DEFAULT NULL COMMENT '看的哪家影院(cinemas.id)', PRIMARY KEY (`event_id`), - CONSTRAINT `fk_mdet_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE + KEY `idx_mdet_cinema` (`cinema_id`), + CONSTRAINT `fk_mdet_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_mdet_cinema` FOREIGN KEY (`cinema_id`) REFERENCES `cinemas` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='观影事件的特有数据(一对一卫星表)' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `movie_reviews` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `event_id` INT UNSIGNED NOT NULL, + `person_id` INT UNSIGNED NOT NULL COMMENT '评价人(登录账号背后的人);与观影人无关,没去看的也能评', + `rating` TINYINT NOT NULL COMMENT '评分 1-10(录入校验 1-10;迁移兜底可存 0)', + `content` TEXT NOT NULL COMMENT '评价正文', + `feelings` VARCHAR(255) DEFAULT NULL COMMENT '观后感受 key 的 JSON 数组(目录见 MovieReview::CATALOG)', + `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_review_event_person` (`event_id`, `person_id`), + KEY `idx_review_event` (`event_id`), + KEY `idx_review_person` (`person_id`), + CONSTRAINT `fk_review_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_review_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_review_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='观影评价:一部电影 × 一个账号 至多一条(MovieLog 同款)' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `movie_review_comments` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `review_id` INT UNSIGNED NOT NULL, + `person_id` INT UNSIGNED NOT NULL COMMENT '评论人(登录账号背后的人)', + `parent_id` INT UNSIGNED DEFAULT NULL COMMENT 'NULL=顶层评论;非空=所属顶层评论 id(回复的回复会被规范化到顶层,页面永远只有两层)', + `reply_to_person_id` INT UNSIGNED DEFAULT NULL COMMENT '被回复的人(显示「回复 @谁」用;存人不存评论 id,对方删评不留悬空)', + `content` TEXT NOT NULL, + `feelings` VARCHAR(255) DEFAULT NULL COMMENT '感受 key 的 JSON 数组', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_mrc_review` (`review_id`), + KEY `idx_mrc_parent` (`parent_id`), + CONSTRAINT `fk_mrc_review` FOREIGN KEY (`review_id`) REFERENCES `movie_reviews` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_mrc_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_mrc_replyto` FOREIGN KEY (`reply_to_person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评价下的讨论(两级平铺,MovieLog 同款)' SQL, <<<'SQL' CREATE TABLE IF NOT EXISTS `event_attendees` ( @@ -412,6 +475,44 @@ SQL, } }); + // ---- 观影对齐 MovieLog(版本 9)---- + // starts_at DATE → DATETIME:MODIFY 是幂等的,老数据的时间部分自动 00:00:00。 + $db->exec("ALTER TABLE `events` MODIFY COLUMN `starts_at` + DATETIME DEFAULT NULL COMMENT '发生时刻(观影=开场时间,精确到秒)'"); + self::addColumn($db, 'events', 'ends_at', + "ADD COLUMN `ends_at` DATETIME DEFAULT NULL COMMENT '结束时刻(观影=散场时间,精确到秒)' AFTER `starts_at`"); + + // 旧 movie_details 的「影院三段 + rating/review/feelings」搬进 cinemas / + // movie_reviews,然后删列。⚠️ 回填+删列是同一条升级路径,**都不能进 tryStep**: + // 回填是删列的唯一前提,半途失败还继续删 = 直接丢数据;让异常炸出来、 + // 下次请求重跑(回填都是幂等写法,重跑安全)。 + // 判据用「旧列还在不在」:新装库(建表语句已是新结构)天然跳过。 + // + // ⚠️ 老库的 movie_details **没有 cinema_id**(v8 及以前影院三段就在自己身上), + // 上面的 CREATE TABLE IF NOT EXISTS 对已存在的表是空操作、补不了列 —— + // 必须显式加列,否则下面的回填 UPDATE 直接炸 1054(2026-09-30 升级专测抓到, + // 线上老库走同一条路径也会炸在那里,升级永远跑不完)。 + self::addColumn($db, 'movie_details', 'cinema_id', + "ADD COLUMN `cinema_id` INT UNSIGNED DEFAULT NULL COMMENT '看的哪家影院(cinemas.id)' AFTER `ticket_key`"); + if (!self::hasIndex($db, 'movie_details', 'idx_mdet_cinema')) { + $db->exec('ALTER TABLE `movie_details` ADD KEY `idx_mdet_cinema` (`cinema_id`)'); + } + // 外键给「删影院 ⇒ 观影记录的 cinema_id 置空」兜底;老库缺了补上。 + // 容错跑:就算哪家老库有脏数据加不上 FK,也不能挡住下面的回填与删列。 + self::tryStep('老库补 movie_details→cinemas 外键', static function () use ($db) { + if (!self::hasForeignKey($db, 'movie_details', 'fk_mdet_cinema')) { + $db->exec('ALTER TABLE `movie_details` + ADD CONSTRAINT `fk_mdet_cinema` FOREIGN KEY (`cinema_id`) REFERENCES `cinemas` (`id`) ON DELETE SET NULL'); + } + }); + if (self::hasColumn($db, 'movie_details', 'cinema_name')) { + self::backfillCinemasFromDetails($db); + self::backfillMovieReviewsFromDetails($db); + foreach (['rating', 'review', 'feelings', 'cinema_place', 'cinema_name', 'cinema_address'] as $col) { + self::dropColumnIfExists($db, 'movie_details', $col); + } + } + // 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 —— // 不然「升级没跑完」这件事只活在日志里,跟没发生一样。 if (self::$stepErrors) { @@ -486,6 +587,85 @@ SQL, } } + /** + * v9:把旧 movie_details 的影院三段(place/name/address)归一进 cinemas, + * 去重键 = (family_id, place, name),再回填 movie_details.cinema_id。 + * 幂等:影院按唯一键查过再插;cinema_id 覆盖写。 + */ + private static function backfillCinemasFromDetails(\PDO $db): void + { + $rows = $db->query( + 'SELECT d.event_id, e.family_id, d.cinema_place, d.cinema_name, d.cinema_address + FROM movie_details d JOIN events e ON e.id = d.event_id + WHERE d.cinema_name IS NOT NULL AND d.cinema_name <> \'\'' + )->fetchAll(); + if (!$rows) { + return; + } + $chk = $db->prepare('SELECT id FROM cinemas WHERE family_id = ? AND place = ? AND name = ?'); + $ins = $db->prepare('INSERT INTO cinemas (family_id, place, name, address, color) VALUES (?, ?, ?, ?, "#888888")'); + $upd = $db->prepare('UPDATE movie_details SET cinema_id = ? WHERE event_id = ?'); + foreach ($rows as $r) { + $place = trim((string)($r['cinema_place'] ?? '')); + $name = trim((string)$r['cinema_name']); + $chk->execute([(int)$r['family_id'], $place, $name]); + $cid = $chk->fetchColumn(); + if ($cid === false) { + $addr = trim((string)($r['cinema_address'] ?? '')); + $ins->execute([(int)$r['family_id'], $place, $name, $addr !== '' ? $addr : null]); + $cid = (int)$db->lastInsertId(); + } + $upd->execute([(int)$cid, (int)$r['event_id']]); + } + } + + /** + * v9:把旧 movie_details 的单条 rating/review/feelings 迁成 movie_reviews + * (评价人 = 记这条事件的人;creator 缺失时退而求其次用该家第一个账号)。 + * v8 感受存的是中文标签,这里反查回 MovieLog 的英文 key;认不出的丢掉。 + * 幂等:uk_review_event_person 撞了就跳过。 + */ + private static function backfillMovieReviewsFromDetails(\PDO $db): void + { + $rows = $db->query( + 'SELECT d.event_id, e.family_id, e.creator_id, d.rating, d.review, d.feelings + FROM movie_details d JOIN events e ON e.id = d.event_id + WHERE d.rating IS NOT NULL OR (d.review IS NOT NULL AND d.review <> \'\')' + )->fetchAll(); + if (!$rows) { + return; + } + $firstPerson = $db->prepare( + 'SELECT a.person_id FROM accounts a JOIN persons p ON p.id = a.person_id + WHERE p.family_id = ? AND a.status = "active" ORDER BY a.id LIMIT 1' + ); + $chk = $db->prepare('SELECT COUNT(*) FROM movie_reviews WHERE event_id = ? AND person_id = ?'); + $ins = $db->prepare( + 'INSERT INTO movie_reviews (family_id, event_id, person_id, rating, content, feelings) + VALUES (?, ?, ?, ?, ?, ?)' + ); + foreach ($rows as $r) { + $pid = $r['creator_id'] !== null ? (int)$r['creator_id'] : 0; + if ($pid <= 0) { + $firstPerson->execute([(int)$r['family_id']]); + $pid = (int)$firstPerson->fetchColumn(); + } + if ($pid <= 0) { + continue; // 全家连一个账号都没有:评价挂不上人,只能不动它 + } + $chk->execute([(int)$r['event_id'], $pid]); + if ((int)$chk->fetchColumn() > 0) { + continue; + } + $rating = $r['rating'] !== null ? max(0, min(10, (int)$r['rating'])) : 0; + $feel = \App\MovieReview::labelsToKeysJson((string)($r['feelings'] ?? '')); + $ins->execute([ + (int)$r['family_id'], (int)$r['event_id'], $pid, $rating, + trim((string)($r['review'] ?? '')), $feel, + ]); + } + } + /** 列不存在才加 */ private static function addColumn(\PDO $db, string $table, string $column, string $alter): void { @@ -562,6 +742,18 @@ SQL, return (int)$st->fetchColumn() > 0; } + /** 老库补外键前先查它在不在(STATISTICS 里看不到约束名,得查 TABLE_CONSTRAINTS) */ + private static function hasForeignKey(\PDO $db, string $table, string $name): bool + { + $st = $db->prepare( + "SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = ? + AND CONSTRAINT_TYPE = 'FOREIGN KEY'" + ); + $st->execute([$table, $name]); + return (int)$st->fetchColumn() > 0; + } + private static function dropColumnIfExists(\PDO $db, string $table, string $column): void { if (self::hasColumn($db, $table, $column)) { diff --git a/app/helpers.php b/app/helpers.php index 5b4eba1..42dc049 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -101,16 +101,16 @@ function read_dt_input(string $name): ?string return sprintf('%s %02d:%02d:%02d', $date, $h, $i, $s); } -/** 站内链接(统一走 query-string 路由,不依赖 Apache 重写) */ -function url(string $route = 'home', array $query = []): string +/** 站内链接(统一走 query-string 路由,不依赖 Apache 重写);$anchor 是页面内锚点(不带 #) */ +function url(string $route = 'home', array $query = [], ?string $anchor = null): string { $q = array_merge(['r' => $route], $query); - return 'index.php?' . http_build_query($q); + return 'index.php?' . http_build_query($q) . ($anchor !== null && $anchor !== '' ? '#' . rawurlencode($anchor) : ''); } -function redirect(string $route = 'home', array $query = []): void +function redirect(string $route = 'home', array $query = [], ?string $anchor = null): void { - header('Location: ' . url($route, $query)); + header('Location: ' . url($route, $query, $anchor)); exit; } diff --git a/assets/css/app.css b/assets/css/app.css index 96f6e00..3917dd5 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -759,73 +759,333 @@ textarea.input { min-height: 76px; resize: vertical; } .ct-row .ct-label { flex: none; } } -/* ===== 观影(v8,MovieLog 迁入) ===== */ -.movie-list { display: flex; flex-direction: column; gap: 10px; } +/* ===== 观影 v9:瀑布流海报墙 + MovieLog 式详情/评价/影院 ===== */ + +/* ---- 搜索栏 ---- */ +.search-bar { + display: flex; gap: 10px; align-items: center; + background: var(--surface); padding: 8px 16px; + border-radius: 12px; box-shadow: var(--shadow); + border: 1px solid var(--line); max-width: 500px; margin-bottom: 20px; +} +.search-bar input[type="text"] { + flex: 1; border: none; outline: none; padding: 8px 10px; + font-size: 15px; background: transparent; color: var(--ink); +} +.search-bar button { + background: var(--brand); color: #fff; border: none; + padding: 8px 20px; border-radius: 8px; font-weight: 600; + cursor: pointer; white-space: nowrap; +} +.search-bar button:hover { background: var(--brand-2); } +.search-clear { color: var(--muted); font-size: 14px; padding: 8px 4px; white-space: nowrap; } + +/* ---- 瀑布流海报墙 ---- */ +.waterfall { + display: grid; gap: 20px; padding: 4px 0; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); +} .movie-card { - display: flex; gap: 14px; align-items: center; - padding: 10px; background: var(--card-bg, #fff); - border: 1px solid var(--line); border-radius: 12px; + background: var(--surface); border-radius: var(--radius); + overflow: hidden; box-shadow: var(--shadow); cursor: pointer; + border: 1px solid var(--line); display: flex; flex-direction: column; 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; +.movie-card:hover { + transform: translateY(-6px); box-shadow: var(--shadow-l); + border-color: var(--brand-2); text-decoration: none; } -.m-poster-empty { - display: flex; align-items: center; justify-content: center; - color: var(--faint, #B9AE9F); font-size: 20px; font-weight: 600; +.card-image { + width: 100%; aspect-ratio: 2 / 3; background: var(--line-2); + overflow: hidden; display: flex; align-items: center; justify-content: center; + position: relative; flex-shrink: 0; } -.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; +.card-image img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .4s ease; } +.movie-card:hover .card-image img { transform: scale(1.03); } +.placeholder-poster { + width: 100%; height: 100%; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 6px; + background: linear-gradient(135deg, #4a3a30, #2C231D); + color: rgba(255, 255, 255, .55); font-size: 42px; font-weight: 600; } -.rating-lg { font-size: 14px; padding: 2px 10px; } -.m-meta { color: var(--muted); font-size: 13px; } -.m-who { color: var(--muted); font-size: 12px; } +.placeholder-poster .placeholder-title { + font-size: 14px; text-align: center; padding: 0 12px; + color: rgba(255, 255, 255, .75); font-weight: 500; +} +.card-body { padding: 14px 16px 16px; flex: 1; display: flex; flex-direction: column; gap: 6px; } +.card-title { + font-size: 16px; font-weight: 600; color: var(--ink); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.card-meta { + display: flex; flex-wrap: wrap; gap: 6px 12px; + font-size: 13px; color: var(--muted); +} +.card-meta span { display: inline-flex; align-items: center; gap: 3px; } +.meta-rating { color: var(--brand); font-weight: 600; } +.meta-rating-max { color: var(--muted); font-weight: 400; } +.card-viewers { display: flex; flex-wrap: wrap; gap: 4px 8px; margin-top: auto; } +.viewer-tag { + display: inline-flex; align-items: center; gap: 4px; font-size: 12px; + padding: 2px 8px; border-radius: 12px; background: var(--line-2); + color: var(--ink); text-decoration: none; +} +.viewer-tag:hover { text-decoration: none; background: var(--line); } +.viewer-tag img { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; } -.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; +/* ---- 详情页 ---- */ +.detail-hero { + display: grid; grid-template-columns: 300px 1fr; gap: 32px; + background: var(--surface); border-radius: var(--radius); + padding: 28px; box-shadow: var(--shadow); margin-bottom: 20px; + border: 1px solid var(--line); +} +.detail-poster { + border-radius: 12px; overflow: hidden; background: var(--line-2); + aspect-ratio: 2 / 3; display: flex; align-items: center; justify-content: center; +} +.detail-poster img { width: 100%; height: 100%; object-fit: cover; } +.detail-poster .placeholder-poster { font-size: 48px; } +.detail-poster .placeholder-poster .placeholder-title { font-size: 16px; } +.detail-info { display: flex; flex-direction: column; gap: 12px; min-width: 0; } +.detail-info h1 { font-size: 26px; font-weight: 700; margin: 0 0 4px; color: var(--ink); } +.detail-info .meta-row { display: flex; flex-wrap: wrap; gap: 8px 16px; font-size: 14px; color: var(--muted); } +.detail-info .meta-row span { display: inline-flex; align-items: center; gap: 4px; } +.info-text { color: var(--ink); line-height: 1.7; font-size: 15px; } +.remark-text { + background: var(--line-2); padding: 12px 16px; border-radius: 8px; + color: var(--ink); border-left: 4px solid var(--brand); font-size: 14px; +} +.detail-section { + background: var(--surface); border-radius: var(--radius); + padding: 24px 28px; box-shadow: var(--shadow); margin-bottom: 20px; + border: 1px solid var(--line); +} +.detail-section h3 { + font-size: 17px; font-weight: 600; margin: 0 0 16px; color: var(--ink); + display: flex; align-items: center; gap: 10px; +} +.detail-section h3 .badge { + font-size: 13px; font-weight: 400; background: var(--line-2); + padding: 2px 12px; border-radius: 20px; color: var(--muted); +} +.empty-hint { color: var(--muted); font-size: 14px; margin: 0; } +.viewers-list { display: flex; flex-wrap: wrap; gap: 12px; } +.viewers-list .viewer-tag { padding: 4px 12px 4px 6px; font-size: 14px; } +.viewers-list .viewer-tag img, +.viewers-list .rv-avatar-text { width: 28px; height: 28px; } +.cinema-info { + display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; font-size: 15px; +} +.cinema-info .label { color: var(--muted); } +.cinema-info .value { color: var(--ink); font-weight: 500; } + +/* ---- 影像记录 ---- */ +.gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 16px; } +.gallery-item { + border-radius: 10px; overflow: hidden; background: var(--line-2); + aspect-ratio: 4 / 3; display: flex; align-items: center; justify-content: center; + cursor: pointer; transition: transform .2s; position: relative; + border: 1px solid var(--line); +} +.gallery-item:hover { transform: scale(1.02); } +.gallery-item img { width: 100%; height: 100%; object-fit: cover; display: block; } +.gallery-item .label-badge { + position: absolute; bottom: 8px; left: 8px; + background: rgba(0, 0, 0, .7); color: #fff; font-size: 11px; + padding: 2px 12px; border-radius: 12px; +} + +/* ---- 图片灯箱 ---- */ +.lb-openable { cursor: zoom-in; } +.lb-openable:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; } +.lightbox { + position: fixed; inset: 0; z-index: 300; display: none; align-items: center; justify-content: center; - color: var(--faint, #B9AE9F); font-size: 40px; font-weight: 600; + background: rgba(0, 0, 0, .88); overscroll-behavior: contain; } -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; +.lightbox.open { display: flex; } +.lb-stage { display: flex; align-items: center; justify-content: center; max-width: 92vw; max-height: 82vh; } +.lb-img { + max-width: 92vw; max-height: 78vh; width: auto; height: auto; + object-fit: contain; border-radius: 8px; box-shadow: 0 20px 60px rgba(0, 0, 0, .5); + user-select: none; -webkit-user-drag: none; } -.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; } +.lb-btn { + position: absolute; border: none; cursor: pointer; color: #fff; + background: rgba(255, 255, 255, .12); display: flex; + align-items: center; justify-content: center; + transition: background-color .2s ease; font-family: inherit; +} +.lb-btn:hover { background: rgba(255, 255, 255, .26); } +.lb-btn[hidden] { display: none; } +.lb-close { top: 14px; right: 16px; width: 40px; height: 40px; border-radius: 50%; font-size: 18px; } +.lb-nav { top: 50%; transform: translateY(-50%); width: 48px; height: 48px; border-radius: 50%; font-size: 28px; padding-bottom: 4px; } +.lb-prev { left: 14px; } +.lb-next { right: 14px; } +.lb-bar { + position: absolute; left: 0; right: 0; bottom: 0; padding: 14px 20px; + display: flex; align-items: center; justify-content: space-between; gap: 12px; + color: #fff; font-size: 14px; + background: linear-gradient(to top, rgba(0, 0, 0, .6), rgba(0, 0, 0, 0)); +} +.lb-caption { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.lb-counter { flex: 0 0 auto; opacity: .75; font-variant-numeric: tabular-nums; } -.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); } +/* ---- 评价与评论 ---- */ +.rv-composer > summary { list-style: none; cursor: pointer; } +.rv-composer > summary::-webkit-details-marker { display: none; } +.rv-open-btn { + display: inline-block; padding: 8px 18px; border-radius: 10px; + background: linear-gradient(135deg, var(--brand), var(--brand-2)); + color: #fff; font-size: 14px; font-weight: 600; user-select: none; +} +.rv-open-btn:hover { text-decoration: none; box-shadow: var(--shadow-l); } +.rv-composer[open] > .rv-open-btn { background: var(--line-2); color: var(--muted); } +.rv-act-btn { + display: inline-block; padding: 4px 12px; border: 1px solid var(--line); + border-radius: 20px; background: var(--surface); color: var(--muted); + font-size: 12.5px; user-select: none; cursor: pointer; +} +.rv-act-btn:hover { border-color: var(--brand); color: var(--brand); text-decoration: none; } +.rv-composer[open] > .rv-act-btn { border-color: var(--brand); background: rgba(168, 74, 40, .08); color: var(--brand); font-weight: 600; } +.rv-form { background: var(--line-2); border-radius: 12px; padding: 16px 18px; margin-top: 10px; } +.rv-composer > .rv-form { margin-bottom: 0; } +.rv-form-inline { padding: 12px 14px; } +.rv-rating-row { display: flex; align-items: center; gap: 8px 14px; flex-wrap: wrap; margin-bottom: 12px; } +.rv-rating-label { font-size: 14px; font-weight: 600; color: var(--ink); } +.rv-scale { display: flex; gap: 6px; flex-wrap: wrap; } +.rv-num { position: relative; } +.rv-num input { position: absolute; width: 1px; height: 1px; opacity: 0; } +.rv-num span { + display: inline-flex; align-items: center; justify-content: center; + min-width: 34px; height: 34px; padding: 0 6px; + border: 1px solid var(--line); border-radius: 8px; background: var(--surface); + color: var(--muted); font-size: 14px; font-weight: 600; + cursor: pointer; user-select: none; +} +.rv-num:hover span { border-color: var(--brand); color: var(--brand); } +.rv-num input:checked + span { background: var(--brand); border-color: transparent; color: #fff; } +.rv-num:focus-within span { outline: 2px solid var(--brand); outline-offset: 1px; } +.rv-word { font-size: 13px; color: var(--muted); } +.rv-word.is-set { color: var(--brand); font-weight: 600; } +.rv-textarea { + width: 100%; min-height: 92px; resize: vertical; padding: 12px 14px; + border: 1px solid var(--line); border-radius: 10px; font-size: 15px; + font-family: inherit; line-height: 1.7; background: var(--surface); + color: var(--ink); box-sizing: border-box; +} +.rv-textarea:focus { outline: none; border-color: var(--brand); } +.rv-textarea-sm { min-height: 56px; font-size: 14px; } +.rv-form-foot { display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin-top: 10px; flex-wrap: wrap; } +.rv-form-foot .rv-hint { margin-right: auto; } +.rv-hint { font-size: 12px; color: var(--muted); } +.rv-pick-label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; } +.rv-empty { color: var(--muted); font-size: 14px; } +.rv-list { display: flex; flex-direction: column; gap: 18px; } +.rv-item { border-top: 1px solid var(--line-2); padding-top: 16px; } +.rv-item:first-child { border-top: none; padding-top: 0; } +.rv-head { display: flex; align-items: center; gap: 10px; } +.rv-avatar { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; } +.rv-avatar-sm { width: 26px; height: 26px; } +.rv-avatar-text { + display: inline-flex; align-items: center; justify-content: center; + background: var(--line-2); color: var(--muted); font-weight: 600; font-size: 15px; +} +.rv-who { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.rv-name { font-size: 14px; font-weight: 600; color: var(--ink); } +.rv-mine { + display: inline-block; vertical-align: middle; margin-left: 6px; + font-size: 11px; line-height: 16px; background: var(--brand); + color: #fff; border-radius: 6px; padding: 0 6px; +} +.rv-time { display: block; font-size: 12px; color: var(--muted); } +.rv-score { flex: 0 0 auto; font-size: 20px; font-weight: 700; color: var(--brand); font-variant-numeric: tabular-nums; } +.rv-score-max { font-size: 12px; font-weight: 500; color: var(--muted); } +.rv-del { margin-left: auto; } +.rv-del-btn { + background: none; border: none; cursor: pointer; color: var(--muted); + font-size: 14px; line-height: 1; padding: 4px 6px; border-radius: 6px; +} +.rv-del-btn:hover { background: rgba(192, 83, 63, .12); color: var(--red); } +.rv-content { + margin: 8px 0 2px 44px; font-size: 15px; line-height: 1.75; + color: var(--ink); word-break: break-word; +} +.rv-updated { margin-left: 44px; font-size: 12px; color: var(--muted); } +.rv-comments { margin: 10px 0 10px 44px; display: flex; flex-direction: column; gap: 8px; } +.rv-comment { display: flex; gap: 8px; background: var(--line-2); border-radius: 10px; padding: 8px 12px; } +.rv-c-body { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.rv-c-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.rv-c-head .rv-name { font-size: 13px; } +.rv-c-text { font-size: 14px; line-height: 1.7; color: var(--ink); word-break: break-word; } +.rv-c-acts { margin-top: 6px; } +.rv-thread { display: flex; flex-direction: column; gap: 6px; } +.rv-comment-reply { margin-left: 22px; } +.rv-at { font-size: 12.5px; color: var(--muted); margin-bottom: 2px; } +.rv-at span { color: var(--brand); font-weight: 600; } +.rv-actions { display: flex; align-items: flex-start; gap: 8px 10px; flex-wrap: wrap; margin: 12px 0 0 44px; } +.rv-feelings { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 2px; } +.rv-item > .rv-feelings { margin-left: 44px; } +.rv-feeling-pick { margin-top: 8px; } +.feel-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.feel-chip { display: inline-flex; } +.feel-pick { position: relative; } +.feel-pick input { position: absolute; width: 1px; height: 1px; opacity: 0; } +.feel-inner { + display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; + border: 1px solid var(--line); border-radius: 20px; background: var(--surface); + font-size: 13px; color: var(--muted); cursor: pointer; user-select: none; +} +.feel-pick:hover .feel-inner { border-color: var(--brand); } +.feel-pick input:checked + .feel-inner { border-color: var(--brand); background: rgba(168, 74, 40, .10); color: var(--brand); font-weight: 600; } +.feel-pick:focus-within .feel-inner { outline: 2px solid var(--brand); outline-offset: 1px; } +.feel-emoji { font-size: 14px; } +.feel-chip.is-static { + align-items: center; gap: 4px; padding: 2px 10px; border-radius: 20px; + background: rgba(168, 74, 40, .10); color: var(--brand); font-size: 12px; +} +/* ---- 影院管理页 ---- */ +.cinema-h2 { font-size: 17px; font-weight: 600; margin: 0 0 14px; } +.cinema-select-row { display: flex; gap: 10px; align-items: center; } +.cinema-select-row .select { flex: 1; } +.color-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.color-picker { width: 60px; height: 40px; padding: 2px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); cursor: pointer; } +.color-hex { font-size: 14px; color: var(--muted); font-family: monospace; } +.preset-palette { display: flex; flex-wrap: wrap; gap: 6px; } +.preset-color { + display: inline-block; width: 28px; height: 28px; border-radius: 50%; + cursor: pointer; border: 2px solid transparent; transition: transform .2s, border-color .2s; +} +.preset-color:hover { transform: scale(1.1); } +.preset-color.active { border-color: var(--ink); } +.color-preview { + display: inline-block; width: 14px; height: 14px; border-radius: 3px; + margin-right: 6px; vertical-align: middle; border: 1px solid var(--line); +} +.table-wrap { overflow-x: auto; } +.table { width: 100%; border-collapse: collapse; font-size: 14px; } +.table th { text-align: left; padding: 10px 8px; border-bottom: 2px solid var(--line); color: var(--muted); font-weight: 600; } +.table td { padding: 10px 8px; border-bottom: 1px solid var(--line-2); } +.table tr:hover td { background: var(--line-2); } + +/* ---- 观影响应式 ---- */ @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; } + .waterfall { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; } + .card-body { padding: 10px 12px 12px; } + .card-title { font-size: 14px; } + .card-meta { font-size: 11px; gap: 4px 10px; } + .detail-hero { grid-template-columns: 1fr; padding: 20px; } + .detail-poster { max-width: 240px; margin: 0 auto; } + .cinema-info { grid-template-columns: 1fr; } + .gallery-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } + .detail-section { padding: 16px 18px; } + .detail-info h1 { font-size: 22px; } + .rv-content, .rv-updated, .rv-comments, .rv-actions, .rv-item > .rv-feelings { margin-left: 0; } + .rv-comment-reply { margin-left: 10px; } + .lb-nav { width: 40px; height: 40px; font-size: 24px; } + .lb-prev { left: 6px; } + .lb-next { right: 6px; } + .lb-img { max-width: 88vw; max-height: 70vh; } } diff --git a/index.php b/index.php index 38422e6..7e93f29 100644 --- a/index.php +++ b/index.php @@ -13,11 +13,13 @@ require __DIR__ . '/app/bootstrap.php'; use App\Account; use App\Auth; use App\Birthday; +use App\Cinema; use App\Crypto; use App\Database; use App\Family; use App\Kinship; use App\Movie; +use App\MovieReview; use App\Nickname; use App\Person; use App\PersonGroup; @@ -1337,21 +1339,16 @@ switch ($r) { // 见 App\Movie。可见性与名册同一口径:能登录就能看,能写才能改; // 迁进来的历史记录 source_ref = 'movielog:movie:'(重跑迁移的幂等锚点)。 - /** 观影时间线 —— 按年分组倒序。 */ + /** 观影瀑布流(v9,MovieLog 同款海报墙;带关键词搜索)。 */ 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); + $keyword = trim((string)($_GET['keyword'] ?? '')); + $rows = Movie::all($familyId, $keyword); View::render('movies/index', [ - 'title' => '观影记录', - 'years' => $years, - 'total' => count($rows), + 'title' => '观影记录', + 'rows' => $rows, + 'total' => count($rows), + 'keyword' => $keyword, ], 'movies'); break; @@ -1362,19 +1359,28 @@ switch ($r) { flash('err', '找不到这条观影记录。'); redirect('movies'); } + $meId = (int)Auth::personId(); + $rvError = ''; View::render('movies/show', [ - 'title' => (string)$m['title'], - 'm' => $m, + 'title' => (string)$m['title'], + 'm' => $m, + 'reviews' => MovieReview::forEvent((int)$m['id']), + 'rvStats' => MovieReview::stats((int)$m['id']), + 'myReview' => MovieReview::mine((int)$m['id'], $meId), + 'meId' => $meId, + 'rvError' => $rvError, + 'feelCat' => MovieReview::CATALOG, ], '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' => [], + 'title' => '记一场电影', + 'm' => null, + 'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''), + 'cinemas' => Cinema::all((int)Auth::familyId()), + 'errors' => [], ], 'movies'); break; @@ -1387,10 +1393,11 @@ switch ($r) { redirect('movies'); } View::render('movies/form', [ - 'title' => '编辑观影记录', - 'm' => $m, - 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), - 'errors' => [], + 'title' => '编辑观影记录', + 'm' => $m, + 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), + 'cinemas' => Cinema::all($familyId), + 'errors' => [], ], 'movies'); break; @@ -1410,18 +1417,22 @@ switch ($r) { redirect('movies'); } + // 起止时刻精确到秒(_datetime.php 控件 → read_dt_input) + $startsAt = read_dt_input('starts_at'); + $endsAt = read_dt_input('ends_at'); + $cinemaId = (int)($_POST['cinema_id'] ?? 0); + if ($cinemaId > 0 && !Cinema::find($familyId, $cinemaId)) { + $cinemaId = 0; // 表单可改:只认本家的影院 + } + $d = [ 'title' => trim((string)($_POST['title'] ?? '')), - 'starts_at' => trim((string)($_POST['starts_at'] ?? '')), + 'starts_at' => (string)($startsAt ?? ''), + 'ends_at' => (string)($endsAt ?? ''), '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'] ?? '')), + 'cinema_id' => $cinemaId, // 同行的人:只认「确实存在且属于这个家」的 id —— 表单可以改, // 不能凭它把别人家的成员挂进自己家的观影记录里(会顺着详情页漏人名)。 'attendees' => array_map('intval', (array)($_POST['attendees'] ?? [])), @@ -1429,7 +1440,13 @@ switch ($r) { $_familyPeople = Person::map($familyId); $d['attendees'] = array_values(array_filter($d['attendees'], static fn($pid) => isset($_familyPeople[$pid]))); - $errors = $d['title'] === '' ? ['title' => '片名不能为空'] : []; + $errors = []; + if ($d['title'] === '') { + $errors['title'] = '片名不能为空'; + } elseif ($d['starts_at'] !== '' && $d['ends_at'] !== '' + && strcmp($d['ends_at'], $d['starts_at']) < 0) { + $errors['ends_at'] = '散场时间不能早于开场时间'; + } // ⚠️ 图片上传失败**不挡保存**(与头像同一思路:一条记录因为一张 5MB 原图 // 存不进去,看起来像功能坏了)。现在是先存记录,图片的事单独提示。 @@ -1479,10 +1496,11 @@ switch ($r) { if ($errors) { set_old($_POST); View::render('movies/form', [ - 'title' => $isEdit ? '编辑观影记录' : '记一场电影', - 'm' => $m, - 'people' => Person::roster($familyId, $me, 'member', ''), - 'errors' => $errors, + 'title' => $isEdit ? '编辑观影记录' : '记一场电影', + 'm' => $m, + 'people' => Person::roster($familyId, $me, 'member', ''), + 'cinemas' => Cinema::all($familyId), + 'errors' => $errors, ], 'movies'); exit; } @@ -1552,6 +1570,149 @@ switch ($r) { redirect('movies'); break; + // ---------------- 影院管理(v9,MovieLog 同款) ---------------- + + /** 影院管理单页:上面加/编表单,下面列表。GET 带参数时进入编辑态。 */ + case 'movies/cinemas': + $familyId = (int)Auth::familyId(); + $editId = (int)($_GET['edit'] ?? 0); + $editing = $editId > 0 ? Cinema::find($familyId, $editId) : null; + View::render('movies/cinemas', [ + 'title' => '影院管理', + 'cinemas' => Cinema::all($familyId), + 'editing' => $editing, + ], 'cinemas'); + break; + + case 'movies/cinemas_save': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $editing = $id > 0 ? Cinema::find($familyId, $id) : null; + if ($id > 0 && !$editing) { + flash('err', '找不到这家影院。'); + redirect('movies/cinemas'); + } + $err = Cinema::save( + $familyId, + $id, + trim((string)($_POST['place'] ?? '')), + trim((string)($_POST['name'] ?? '')), + trim((string)($_POST['address'] ?? '')) !== '' ? trim((string)($_POST['address'] ?? '')) : null, + Cinema::normalizeColor((string)($_POST['color'] ?? '#888888')) + ); + if ($err !== '') { + flash('err', $err); + redirect('movies/cinemas', $id > 0 ? ['edit' => $id] : []); + } + flash('ok', $id > 0 ? '影院信息已更新。' : '影院已添加。'); + redirect('movies/cinemas'); + break; + + case 'movies/cinemas_del': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + if (Cinema::find($familyId, $id)) { + Cinema::destroy($familyId, $id); + flash('ok', '影院已删除,关联观影记录的影院信息已置空。'); + } else { + flash('err', '找不到这家影院。'); + } + redirect('movies/cinemas'); + break; + + // ---------------- 评价与讨论(v9,MovieLog 同款) ---------------- + // 评价人是「登录账号背后的人」,与同行观影人无关;访客只读(requireWrite)。 + + case 'movies/review_save': + $familyId = (int)Auth::familyId(); + $meId = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $eventId = (int)($_POST['id'] ?? 0); + $m = Movie::find($familyId, $eventId); + if (!$m) { + flash('err', '找不到这条观影记录。'); + redirect('movies'); + } + $my = MovieReview::mine($eventId, $meId); + $err = MovieReview::save( + $eventId, $familyId, $meId, + (int)($_POST['rating'] ?? 0), + trim((string)($_POST['content'] ?? '')), + array_values((array)($_POST['feelings'] ?? [])), + $my !== null + ); + if ($err !== '') { + flash('err', $err); + } else { + flash('ok', $my !== null ? '评价已更新。' : '评价已发布。'); + } + redirect('movies/show', ['id' => $eventId], 'reviews'); + break; + + case 'movies/review_del': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $eventId = (int)($_POST['id'] ?? 0); + if (!Movie::find($familyId, $eventId)) { + flash('err', '找不到这条观影记录。'); + redirect('movies'); + } + if (!MovieReview::deleteMine((int)($_POST['review_id'] ?? 0), (int)Auth::personId())) { + flash('err', '只能删除自己的评价。'); + } else { + flash('ok', '评价已删除。'); + } + redirect('movies/show', ['id' => $eventId], 'reviews'); + break; + + case 'movies/comment_add': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $eventId = (int)($_POST['id'] ?? 0); + $reviewId = (int)($_POST['review_id'] ?? 0); + $parentId = (int)($_POST['parent_id'] ?? 0); + if (!Movie::find($familyId, $eventId)) { + flash('err', '找不到这条观影记录。'); + redirect('movies'); + } + $res = MovieReview::addComment( + $reviewId, (int)Auth::personId(), + trim((string)($_POST['content'] ?? '')), + array_values((array)($_POST['feelings'] ?? [])), + $parentId + ); + if (is_string($res)) { + flash('err', $res); + } else { + flash('ok', $parentId > 0 ? '回复已发表。' : '评论已发表。'); + } + redirect('movies/show', ['id' => $eventId], 'reviews'); + break; + + case 'movies/comment_del': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $eventId = (int)($_POST['id'] ?? 0); + if (!Movie::find($familyId, $eventId)) { + flash('err', '找不到这条观影记录。'); + redirect('movies'); + } + if (!MovieReview::deleteComment((int)($_POST['comment_id'] ?? 0), (int)Auth::personId())) { + flash('err', '只能删除自己的评论。'); + } else { + flash('ok', '评论已删除。'); + } + redirect('movies/show', ['id' => $eventId], 'reviews'); + break; + default: http_response_code(404); View::render('error', [ diff --git a/templates/_datetime.php b/templates/_datetime.php index 0323872..573e625 100644 --- a/templates/_datetime.php +++ b/templates/_datetime.php @@ -37,21 +37,24 @@ $dtSec = $dtTs ? (int)date('s', $dtTs) : 0; aria-label="日期"> @@ -59,5 +62,6 @@ $dtSec = $dtTs ? (int)date('s', $dtTs) : 0; diff --git a/templates/layout.php b/templates/layout.php index f389eb8..44e4bd2 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -7,6 +7,7 @@ $navItems = [ 'kin' => ['家谱', 'kin'], 'people' => ['名册', 'people'], 'movies' => ['观影', 'movies'], + 'cinemas' => ['影院', 'movies/cinemas'], ]; // ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的, // 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。 @@ -37,7 +38,7 @@ $isLoggedIn = \App\Auth::check(); 家书 diff --git a/templates/movies/cinemas.php b/templates/movies/cinemas.php new file mode 100644 index 0000000..935089f --- /dev/null +++ b/templates/movies/cinemas.php @@ -0,0 +1,136 @@ + +
+

影院管理

+ 共 家影院 + +
+ + +
只读账号访客只能查看影院列表。
+ +
+

+
+ + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + + + + +
+
+
+
+ + + 取消编辑 + +
+
+
+ + +
+

影院列表

+ +

还没有影院,先在上面添加一家。

+ +
+ + + + + + + + + + + + + + + + + +
名称所在地方详细地址观影操作
+ + + 场 +
+ 编辑 +
+ + + +
+
+
+
+ +
+ + + + diff --git a/templates/movies/form.php b/templates/movies/form.php index fda2bc3..991fc7d 100644 --- a/templates/movies/form.php +++ b/templates/movies/form.php @@ -2,24 +2,31 @@ /** * 观影表单(新增 / 编辑,一张表单两用 —— 与 people/form 同一思路)。 * - * 字段分三块:看什么(片名/日期/评分/感受)、跟谁(同行的人)、在哪看(影院三段), - * 再加自由文本(简介/观后感/备注)。海报与票根是 free 槽位(v8): - * 不进裁剪器、整图等比缩进框内,所以这里不放裁剪框,只放普通文件控件。 + * v9 变化:观影时间改为「开始 / 结束」两个秒级时刻(_datetime.php 控件, + * 服务端 read_dt_input 取值);评分与观后感**移出表单** —— 搬到详情页的 + * 「评价与评论」区(MovieLog 同款:每账号一条评价,谁都能评);影院改为 + * 下拉选择已有影院 + 可选「新建影院」行内三段。 * - * 现场照片只在**新增**时提供多选;编辑时在详情页里逐张补/删 —— - * 一次改一处的表单,比一个巨型表单不容易出错。 + * 海报与票根是 free 槽位(v8):不进裁剪器、整图等比缩进框内,普通文件控件即可。 + * 现场照片只在**新增**时提供多选;编辑时在详情页里逐张补/删。 */ $isEdit = !empty($m['id']); -// 回填:校验失败重画时用用户刚提交的值(old()),否则用库里的值 +// 时间回填:校验失败重画时优先用 old() 里的分字段重组(否则用户刚选的时刻会丢); +// 正常进编辑页时用库里的值。 +$dtFromOld = static function (string $name, ?string $dbValue): ?string { + $date = trim((string)old($name . '_date', '')); + if ($date !== '') { + return sprintf('%s %02d:%02d:%02d', $date, + (int)old($name . '_hour', 0), (int)old($name . '_minute', 0), (int)old($name . '_second', 0)); + } + return ($dbValue !== null && $dbValue !== '') ? $dbValue : null; +}; +$dtStart = $dtFromOld('starts_at', $m['starts_at'] ?? null); +$dtEnd = $dtFromOld('ends_at', $m['ends_at'] ?? null); +$selCinema = (int)old('cinema_id', $m['cinema_id'] ?? 0); $curAttendees = array_map('intval', array_column($m['attendees'] ?? [], 'id')); -$curFeelings = []; -if (!empty($m['feelings'])) { - $curFeelings = json_decode((string)$m['feelings'], true) ?: []; -} $selAttendees = array_map('intval', (array)old('attendees', $curAttendees)); -$selFeelings = (array)old('feelings', $curFeelings); -$rtVal = old('rating', $m['rating'] ?? ''); ?>

@@ -41,34 +48,34 @@ $rtVal = old('rating', $m['rating'] ?? '');
-
- - -
- -
- - +
+ + +
- -
- - - + + + +
+ +
+ +
+ + 管理影院
- MovieLog 里记的英文标签(warm / excited…)搬过来时已经翻成中文。 + 想记一家还没录过的影院?先去「管理影院」里添加,回来这里就能选。
@@ -88,36 +95,12 @@ $rtVal = old('rating', $m['rating'] ?? '');
-
- - -
- -
- - -
- -
- - -
-
-
- - -
-
diff --git a/templates/movies/index.php b/templates/movies/index.php index 25ba6b7..5d0018d 100644 --- a/templates/movies/index.php +++ b/templates/movies/index.php @@ -1,61 +1,79 @@

观影记录

- 一共 场 + + + 找到 部匹配「」的电影 + + 共 部 · 按观影时间排序 + +
+ +
- 还没有观影记录 - 一家人一起看过的电影,都会按年份排在这里。 + +
- $list): ?> -
-

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

-
- + + + + + + 📅 + + 🏛️ + + + ⭐ /10 + + + + 👥 + + + + +
diff --git a/templates/movies/show.php b/templates/movies/show.php index f0a63e6..4d789a9 100644 --- a/templates/movies/show.php +++ b/templates/movies/show.php @@ -1,25 +1,126 @@ '; + } + return '' . e(mb_substr($name, 0, 1, 'UTF-8')) . ''; +}; + +/** 可勾选的感受 chips(原生 checkbox,外观在 .feel-inner 上) */ +$feelPick = static function (array $checked = []) use ($feelCat) { + ob_start(); ?> + + $f): ?> + + + + + + + + + + + + + +
> + +
+ + +
+ 我的评分 + + + + + + + + 0 ? $myRating . ' 分 · ' . e(\App\MovieReview::ratingWord($myRating)) : '未评分' ?> + +
+ +
+ 我看完的感受(可多选) + +
+
+ 每个账号对每部电影只有一条评价,再次提交即为修改。 + +
+
+
+ 0 是回复某条评论) */ +$commentBox = static function (int $reviewId, int $parentId, string $summary) use ($m, $feelPick) { + ob_start(); ?> +
+ +
+ + + + + +
+ 观后感受(可多选) + +
+
+ +
+
+
+
-

- + 观影记录
编辑 @@ -30,80 +131,90 @@ $hasTicket = !empty($m['ticket_key']); - 返回 + 返回列表
-
-
+ +
+
data-lb-index="0" role="button" tabindex="0" aria-label="放大查看海报" title="点击查看大图"> - - 海报 - + 海报 - - - - - 票根 - + + + +
- -
- -
- - - - - - -
- - -
-
观影日期
- -
影院
- $s !== '')))) ?> -
+
+

+
+ 📅 + + 🏛️ - -
地点
- -
同行的人
- - $a): ?> 0 ? '、' : '' ?> - 一个人看的 -
-
- + 👥 观影人 人 +
-

简介

- - -

观后感

+
-

备注

+
📝
- -
-

现场照片 张

-
- -
- - - + +
+

一起看的人 人

+ +
+ + + + + + +
+ +

一个人看的

+ +
+ + +
+

电影院信息

+ +
+ 所在地方 + + 影院名称 + + 详细地址 + +
+ +

未记录电影院信息

+ +
+ + + +
+

影像记录 张

+ -
+
- -
- +
+ @@ -130,5 +240,250 @@ $hasTicket = !empty($m['ticket_key']);
-
+
+ + + +
+

评价与评论 + + 0): ?> + 条评价 · 均分 + + 还没有评价 + + +

+ + + + + + +

还没有评价,等你来评一个。

+ +
+ + +
+
+ + + + 我 + + + /10 + +
+ + + + +
+ +
+
+ + +
已编辑
+ + + +
+ + +
+
+ + + + + 我 + + +
+ + + + +
+ +
+ + + +
+
+ + +
+ + + + + 我 + + +
+ + + + +
+ +
+ + 回复 @ + + + + +
+
+ +
+ +
+ + + +
+ + + + +
+ +
+ +
+ +
+ + + + + + + + + +