v9: 观影对齐 MovieLog —— 瀑布流/影院管理/评价评论/起止时间

- events 起止时间精确到秒(starts_at DATE→DATETIME + ends_at),_datetime.php 复用
- 影院独立成表(cinemas,地方/名称/地址/主题色 + 50 色板),movie_details 归一为 cinema_id
- 评价模型 MovieLog 同款:movie_reviews 每账号每片一条(1-10 分+正文+感受 key),
  movie_review_comments 两级平铺评论(回复的回复规范化到顶层、跨评价回复拦截)
- 感受目录迁到 MovieReview::CATALOG(12 种,Emoji 11.0 老码位)
- 观影列表改瀑布流(CSS grid 海报墙),详情页五大块 + 灯箱 + 评价区
- 升级路径:v8 老数据自动归一(影院三段按 (family,place,name) 去重建行、
  评价回填、中文感受反查英文 key),回填全部幂等;老库补 cinema_id 列+索引+外键
- 修三 bug:建表顺序(cinemas 必须在 movie_details 前,否则全新安装 1215)、
  layout 导航用键名当路由(影院链接指向 r=cinemas)、
  _datetime.php 分钟循环变量 $m 覆盖宿主模板的观影记录(编辑页简介/备注回填被清空)
This commit is contained in:
2026-10-01 00:12:04 +08:00
parent ff353b1eee
commit 746f02581e
13 changed files with 2001 additions and 356 deletions
+110
View File
@@ -0,0 +1,110 @@
<?php
/**
* 影院(v9,MovieLog 同款独立管理):所在地方 / 名称 / 详细地址 / 主题色。
*
* 观影记录通过 movie_details.cinema_id 关联(外键 ON DELETE SET NULL:
* 删影院不停掉历史记录,只是它们的影院信息置空 —— 与 MovieLog 行为一致)。
* 去重键 (family_id, place, name):同一家影院不会存两份。
*/
declare(strict_types=1);
namespace App;
final class Cinema
{
/** 50 色预设板(MovieLog cinemas.php 同一套) */
public const PRESET_COLORS = [
'#e74c3c', '#c0392b', '#e67e22', '#d35400', '#f39c12',
'#f1c40f', '#f7dc6f', '#f5b041', '#f4d03f', '#f9e79f',
'#f8c471', '#2ecc71', '#27ae60', '#1abc9c', '#16a085',
'#58d68d', '#48c9b0', '#45b39d', '#82e0aa', '#a3e4d7',
'#3498db', '#2980b9', '#2e86c1', '#5dade2', '#85c1e9',
'#5499c7', '#aed6f1', '#9b59b6', '#8e44ad', '#af7ac5',
'#bb8fce', '#d2b4de', '#e84393', '#fd79a8', '#e91e63',
'#f06292', '#95a5a6', '#7f8c8d', '#bdc3c7', '#d5dbdb',
'#aab7b8', '#99a3a4', '#a67c52', '#8d6e63', '#d4a574',
'#c9a87c', '#00b894', '#0984e3', '#6c5ce7', '#fd79a8',
];
/** @return array<int,array> 按地方、名称排序的全部影院 */
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';
}
}
+90 -63
View File
@@ -3,16 +3,18 @@
* 观影记录(统一事件原语的第一个落地种类)。 * 观影记录(统一事件原语的第一个落地种类)。
* *
* 归一模型(见 familybook.md 的核心设计): * 归一模型(见 familybook.md 的核心设计):
* events = 一切记录的共同骨架(标题 / 日期 / 地点 / 来源引用) * events = 一切记录的共同骨架(标题 / 起止时刻 / 地点 / 来源引用)
* movie_details = 观影特有的卫星表(海报 / 票根 / 评分 / 影评 / 影院三段) * movie_details = 观影特有的卫星表(海报 / 票根 / 关联影院)
* attendees = 同行观影的人(persons.id) * attendees = 同行观影的人(persons.id)
* photos = 现场照片(可多张) * photos = 现场照片(可多张)
* reviews = 评价与讨论(movie_reviews / movie_review_comments,见 MovieReview)
* cinemas = 影院独立管理(见 Cinema)
* *
* 图片一律存**相对键**(如 posters/xxx.jpg),地址由 media.php 代理 —— * 图片一律存**相对键**(如 posters/xxx.jpg),地址由 media.php 代理 ——
* 本地与 OSS 的差别被 Storage 吃掉,这里永远不碰完整 URL。 * 本地与 OSS 的差别被 Storage 吃掉,这里永远不碰完整 URL。
* *
* source_ref 是迁移幂等的锚点:MovieLog 整体迁入时写成 'movielog:movie:<id>', * source_ref 是迁移幂等的锚点:MovieLog 整体迁入时写成 'movielog:movie:<id>',
* 重跑迁移按它先删后插,不会出现一半新一半旧的中间态。 * 重跑迁移按它先查再插,不会出现一半新一半旧的中间态。
*/ */
declare(strict_types=1); declare(strict_types=1);
@@ -21,49 +23,48 @@ namespace App;
final class Movie final class Movie
{ {
/** 观后感受标签(MovieLog 迁移时把英文键映射成中文,这里就是唯一清单) */ /** @return array<int,array> 观影记录,按开场时刻倒序(瀑布流用)。
public const FEELINGS = ['温暖', '激动', '欢乐', '深思', '伤感', '震撼', '治愈', '无聊']; * $keyword 非空时搜:片名 / 影院名 / 同行人名(LIKE)。 */
public static function all(int $familyId, string $keyword = ''): array
/** MovieLog 的英文感受键 → 中文标签(没见过的键原样保留) */
public const FEELING_MAP = [
'warm' => '温暖',
'excited' => '激动',
'thoughtful' => '深思',
'sad' => '伤感',
'funny' => '欢乐',
'happy' => '欢乐',
'boring' => '无聊',
'moved' => '治愈',
'shocked' => '震撼',
];
/** @return array<int,array> 按年份倒序的全部观影记录(时间线用) */
public static function all(int $familyId): array
{ {
$st = Database::get()->prepare( $sql =
'SELECT e.id, e.title, e.starts_at, e.location, e.note, 'SELECT e.id, e.title, e.starts_at, e.ends_at, e.note,
d.poster_key, d.ticket_key, d.rating, d.cinema_name, d.cinema_place, 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 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 "、") (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 FROM event_attendees a JOIN persons p ON p.id = a.person_id
WHERE a.event_id = e.id) AS attendee_names WHERE a.event_id = e.id) AS attendee_names
FROM events e FROM events e
LEFT JOIN movie_details d ON d.event_id = e.id LEFT JOIN movie_details d ON d.event_id = e.id
WHERE e.family_id = ? AND e.kind = "movie" LEFT JOIN cinemas c ON c.id = d.cinema_id
ORDER BY e.starts_at DESC, e.id DESC' WHERE e.family_id = ? AND e.kind = "movie"';
); $bind = [$familyId];
$st->execute([$familyId]); $kw = trim($keyword);
return $st->fetchAll(); 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 public static function find(int $familyId, int $id): ?array
{ {
$st = Database::get()->prepare( $st = Database::get()->prepare(
'SELECT e.*, d.info, d.poster_key, d.ticket_key, d.rating, d.review, d.feelings, 'SELECT e.*, d.info, d.poster_key, d.ticket_key, d.cinema_id,
d.cinema_place, d.cinema_name, d.cinema_address c.place AS cinema_place, c.name AS cinema_name,
c.address AS cinema_address, c.color AS cinema_color
FROM events e FROM events e
LEFT JOIN movie_details d ON d.event_id = e.id 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"' WHERE e.family_id = ? AND e.id = ? AND e.kind = "movie"'
); );
$st->execute([$familyId, $id]); $st->execute([$familyId, $id]);
@@ -100,18 +101,18 @@ final class Movie
/** /**
* 新建一条观影记录,返回事件 id。 * 新建一条观影记录,返回事件 id。
* $d 字段:title, starts_at(Y-m-d), location, note, info, rating, review, * $d 字段:title, starts_at(Y-m-d H:i:s), ends_at, location, note, info,
* feelings(array), cinema_place, cinema_name, cinema_address, * cinema_id(int|null), poster_key, ticket_key, attendees(int[]), photos(string[])
* poster_key, ticket_key, attendees(int[]), photos(string[])
*/ */
public static function create(int $familyId, ?int $creatorId, array $d): int public static function create(int $familyId, ?int $creatorId, array $d): int
{ {
$db = Database::get(); $db = Database::get();
$db->prepare( $db->prepare(
'INSERT INTO events (family_id, creator_id, kind, title, starts_at, location, note, source_ref) 'INSERT INTO events (family_id, creator_id, kind, title, starts_at, ends_at, location, note, source_ref)
VALUES (?, ?, "movie", ?, ?, ?, ?, ?)' VALUES (?, ?, "movie", ?, ?, ?, ?, ?, ?)'
)->execute([ )->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'] ?? ''), self::locOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''),
$d['source_ref'] ?? null, $d['source_ref'] ?? null,
]); ]);
@@ -126,9 +127,11 @@ final class Movie
{ {
$db = Database::get(); $db = Database::get();
$db->prepare( $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([ )->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'] ?? ''), self::locOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''),
$id, $familyId, $id, $familyId,
]); ]);
@@ -140,35 +143,22 @@ final class Movie
/** 卫星表 upsert(编辑时可能还没建过这行) */ /** 卫星表 upsert(编辑时可能还没建过这行) */
private static function saveDetails(int $eventId, array $d): void private static function saveDetails(int $eventId, array $d): void
{ {
$db = Database::get(); $db = Database::get();
$feel = array_values(array_filter(array_map( $cid = (int)($d['cinema_id'] ?? 0);
static fn($f) => mb_substr(trim((string)$f), 0, 20, 'UTF-8'), $row = [
(array)($d['feelings'] ?? [])
), static fn($f) => $f !== ''));
$row = [
self::textOrNull($d['info'] ?? ''), self::textOrNull($d['info'] ?? ''),
self::keyOrNull($d['poster_key'] ?? ''), self::keyOrNull($d['poster_key'] ?? ''),
self::keyOrNull($d['ticket_key'] ?? ''), self::keyOrNull($d['ticket_key'] ?? ''),
isset($d['rating']) && $d['rating'] !== '' ? max(0, min(10, (int)$d['rating'])) : null, $cid > 0 ? $cid : 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 = $db->prepare('SELECT COUNT(*) FROM movie_details WHERE event_id = ?');
$has->execute([$eventId]); $has->execute([$eventId]);
if ((int)$has->fetchColumn() > 0) { if ((int)$has->fetchColumn() > 0) {
$db->prepare( $db->prepare('UPDATE movie_details SET info=?, poster_key=?, ticket_key=?, cinema_id=? WHERE event_id=?')
'UPDATE movie_details SET info=?, poster_key=?, ticket_key=?, rating=?, review=?, ->execute([...$row, $eventId]);
feelings=?, cinema_place=?, cinema_name=?, cinema_address=? WHERE event_id=?'
)->execute([...$row, $eventId]);
} else { } else {
$db->prepare( $db->prepare('INSERT INTO movie_details (info, poster_key, ticket_key, cinema_id, event_id) VALUES (?,?,?,?,?)')
'INSERT INTO movie_details (info, poster_key, ticket_key, rating, review, ->execute([...$row, $eventId]);
feelings, cinema_place, cinema_name, cinema_address, 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); $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 null;
} }
return $v; return $v;
+398
View File
@@ -0,0 +1,398 @@
<?php
/**
* 观影评价与讨论(v9,模型照搬 MovieLog 的 lib/reviews.php)。
*
* 数据模型:
* movie_reviews 一部电影 × 一个账号 至多一条(rating 1-10 + 正文 + 感受)
* movie_review_comments 挂在评价下的讨论,两级平铺:
* parent_id 为空 → 顶层评论;非空 → 指向「所属顶层评论」的 id
* (回复的回复会被规范化到顶层,页面永远只有两层),
* reply_to_person_id 记「被回复的人」,显示「回复 @谁」。
*
* 评价人是「登录账号背后的人」(persons.id),与同行观影人无关:
* 没一起去看的人只要有账号,也能打分、写评价并参与讨论。
*
* 感受只存 key(如 warm / sad),emoji 与文案统一在 CATALOG 里维护,
* 改文案 / 换 emoji 都不需要动数据。emoji 只选 Emoji 11.0(2018)及以前的老码位
* —— 更新的字形在旧系统字体里会显示成方框。
*/
declare(strict_types=1);
namespace App;
final class MovieReview
{
public const CONTENT_MAX = 5000; // 评价正文最大字数(UTF-8 字符)
public const COMMENT_MAX = 1000; // 评论正文最大字数
public const FEELING_MAX = 6; // 一条评价/评论最多能选几种感受
/** 观后感受目录:key => [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<int,string> 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;
}
}
+205 -13
View File
@@ -42,8 +42,15 @@ final class Schema
* 影院三段)+ event_attendees(参与人)+ event_photos(现场照片)。 * 影院三段)+ event_attendees(参与人)+ event_photos(现场照片)。
* MovieLog 的 219 部观影记录整体迁入(source_ref='movielog:movie:<id>', * MovieLog 的 219 部观影记录整体迁入(source_ref='movielog:movie:<id>',
* 幂等重跑的锚点)。日历本/CalDAV 是后续阶段,events 先不带 calendar_id。 * 幂等重跑的锚点)。日历本/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[] */ /** @return string[] */
public static function statements(): array 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', `creator_id` INT UNSIGNED DEFAULT NULL COMMENT '谁记的(persons.id);系统迁移可为 NULL',
`kind` ENUM('movie') NOT NULL COMMENT '事件类型:movie=观影(trip/任务等是后续阶段的卫星表)', `kind` ENUM('movie') NOT NULL COMMENT '事件类型:movie=观影(trip/任务等是后续阶段的卫星表)',
`title` VARCHAR(200) NOT NULL COMMENT '事件标题(观影=片名)', `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 '地点(观影=影院名)', `location` VARCHAR(200) DEFAULT NULL COMMENT '地点(观影=影院名)',
`note` TEXT DEFAULT NULL COMMENT '备注', `note` TEXT DEFAULT NULL COMMENT '备注',
`source_ref` VARCHAR(64) DEFAULT NULL COMMENT '外部来源引用(如 movielog:movie:123)——迁移幂等重跑的锚点', `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_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 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='统一事件:观影/旅行/任务…共同点是都有时间、有人参与、要上日历' ) 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,
<<<'SQL' <<<'SQL'
CREATE TABLE IF NOT EXISTS `movie_details` ( CREATE TABLE IF NOT EXISTS `movie_details` (
`event_id` INT UNSIGNED NOT NULL, `event_id` INT UNSIGNED NOT NULL,
`info` TEXT DEFAULT NULL COMMENT '简介(豆瓣抓取的剧情简介等)', `info` TEXT DEFAULT NULL COMMENT '简介(豆瓣抓取的剧情简介等)',
`poster_key` VARCHAR(255) DEFAULT NULL COMMENT '海报(存储相对键,如 posters/xxx.jpg)', `poster_key` VARCHAR(255) DEFAULT NULL COMMENT '海报(存储相对键,如 posters/xxx.jpg)',
`ticket_key` VARCHAR(255) DEFAULT NULL COMMENT '票根照片(存储相对键)', `ticket_key` VARCHAR(255) DEFAULT NULL COMMENT '票根照片(存储相对键)',
`rating` TINYINT DEFAULT NULL COMMENT '评分 0-10(.MovieLog 10 分制原样保留)', `cinema_id` INT UNSIGNED DEFAULT NULL COMMENT '看的哪家影院(cinemas.id)',
`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`), 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='观影事件的特有数据(一对一卫星表)' ) 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,
<<<'SQL' <<<'SQL'
CREATE TABLE IF NOT EXISTS `event_attendees` ( 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) { 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 private static function addColumn(\PDO $db, string $table, string $column, string $alter): void
{ {
@@ -562,6 +742,18 @@ SQL,
return (int)$st->fetchColumn() > 0; 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 private static function dropColumnIfExists(\PDO $db, string $table, string $column): void
{ {
if (self::hasColumn($db, $table, $column)) { if (self::hasColumn($db, $table, $column)) {
+5 -5
View File
@@ -101,16 +101,16 @@ function read_dt_input(string $name): ?string
return sprintf('%s %02d:%02d:%02d', $date, $h, $i, $s); return sprintf('%s %02d:%02d:%02d', $date, $h, $i, $s);
} }
/** 站内链接(统一走 query-string 路由,不依赖 Apache 重写) */ /** 站内链接(统一走 query-string 路由,不依赖 Apache 重写);$anchor 是页面内锚点(不带 #) */
function url(string $route = 'home', array $query = []): string function url(string $route = 'home', array $query = [], ?string $anchor = null): string
{ {
$q = array_merge(['r' => $route], $query); $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; exit;
} }
+316 -56
View File
@@ -759,73 +759,333 @@ textarea.input { min-height: 76px; resize: vertical; }
.ct-row .ct-label { flex: none; } .ct-row .ct-label { flex: none; }
} }
/* ===== 观影(v8,MovieLog 迁入) ===== */ /* ===== 观影 v9:瀑布流海报墙 + MovieLog 式详情/评价/影院 ===== */
.movie-list { display: flex; flex-direction: column; gap: 10px; }
/* ---- 搜索栏 ---- */
.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 { .movie-card {
display: flex; gap: 14px; align-items: center; background: var(--surface); border-radius: var(--radius);
padding: 10px; background: var(--card-bg, #fff); overflow: hidden; box-shadow: var(--shadow); cursor: pointer;
border: 1px solid var(--line); border-radius: 12px; border: 1px solid var(--line); display: flex; flex-direction: column;
text-decoration: none; color: var(--ink); text-decoration: none; color: var(--ink);
} }
.movie-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-l); text-decoration: none; } .movie-card:hover {
.m-poster { transform: translateY(-6px); box-shadow: var(--shadow-l);
flex: 0 0 64px; width: 64px; height: 96px; object-fit: cover; border-color: var(--brand-2); text-decoration: none;
border-radius: 8px; background: var(--line-2); display: block;
} }
.m-poster-empty { .card-image {
display: flex; align-items: center; justify-content: center; width: 100%; aspect-ratio: 2 / 3; background: var(--line-2);
color: var(--faint, #B9AE9F); font-size: 20px; font-weight: 600; 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; } .card-image img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .4s ease; }
.m-title { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .movie-card:hover .card-image img { transform: scale(1.03); }
.rating { .placeholder-poster {
background: var(--brand); color: #fff; border-radius: 999px; width: 100%; height: 100%; display: flex; flex-direction: column;
padding: 1px 8px; font-size: 12px; font-weight: 600; align-items: center; justify-content: center; gap: 6px;
font-variant-numeric: tabular-nums; 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; } .placeholder-poster .placeholder-title {
.m-meta { color: var(--muted); font-size: 13px; } font-size: 14px; text-align: center; padding: 0 12px;
.m-who { color: var(--muted); font-size: 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; } .detail-hero {
.m-poster-lg { display: grid; grid-template-columns: 300px 1fr; gap: 32px;
width: 100%; aspect-ratio: 2 / 3; object-fit: cover; background: var(--surface); border-radius: var(--radius);
border-radius: 12px; background: var(--line-2); display: flex; 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; 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; } .lightbox.open { display: flex; }
.m-ticket { width: 100%; border-radius: 10px; border: 1px solid var(--line); display: block; } .lb-stage { display: flex; align-items: center; justify-content: center; max-width: 92vw; max-height: 82vh; }
.m-info .m-score { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; } .lb-img {
.badge-feel { background: #F6E3D7; color: var(--brand-ink); } max-width: 92vw; max-height: 78vh; width: auto; height: auto;
.m-block { margin-top: 16px; } object-fit: contain; border-radius: 8px; box-shadow: 0 20px 60px rgba(0, 0, 0, .5);
.m-block h3 { font-size: 14px; margin: 0 0 6px; color: var(--muted); font-weight: 500; } user-select: none; -webkit-user-drag: none;
.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); } .lb-btn {
.photo-add label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 8px; } position: absolute; border: none; cursor: pointer; color: #fff;
.photo-add-row { display: flex; gap: 10px; align-items: center; } background: rgba(255, 255, 255, .12); display: flex;
.photo-add-row .input { flex: 1; } 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; } .rv-composer > summary { list-style: none; cursor: pointer; }
.feel-item input { accent-color: var(--brand); } .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) { @media (max-width: 720px) {
.movie-detail { grid-template-columns: 1fr; } .waterfall { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
.m-poster-col { flex-direction: row; } .card-body { padding: 10px 12px 12px; }
.m-poster-col .m-poster-lg, .m-poster-col a:first-child { flex: 0 0 140px; width: 140px; } .card-title { font-size: 14px; }
.m-ticket { width: auto; max-width: 55%; } .card-meta { font-size: 11px; gap: 4px 10px; }
.photo-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); } .detail-hero { grid-template-columns: 1fr; padding: 20px; }
.photo-cell img { height: 120px; } .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; }
} }
+195 -34
View File
@@ -13,11 +13,13 @@ require __DIR__ . '/app/bootstrap.php';
use App\Account; use App\Account;
use App\Auth; use App\Auth;
use App\Birthday; use App\Birthday;
use App\Cinema;
use App\Crypto; use App\Crypto;
use App\Database; use App\Database;
use App\Family; use App\Family;
use App\Kinship; use App\Kinship;
use App\Movie; use App\Movie;
use App\MovieReview;
use App\Nickname; use App\Nickname;
use App\Person; use App\Person;
use App\PersonGroup; use App\PersonGroup;
@@ -1337,21 +1339,16 @@ switch ($r) {
// 见 App\Movie。可见性与名册同一口径:能登录就能看,能写才能改; // 见 App\Movie。可见性与名册同一口径:能登录就能看,能写才能改;
// 迁进来的历史记录 source_ref = 'movielog:movie:<id>'(重跑迁移的幂等锚点)。 // 迁进来的历史记录 source_ref = 'movielog:movie:<id>'(重跑迁移的幂等锚点)。
/** 观影时间线 —— 按年分组倒序。 */ /** 观影瀑布流(v9,MovieLog 同款海报墙;带关键词搜索)。 */
case 'movies': case 'movies':
$familyId = (int)Auth::familyId(); $familyId = (int)Auth::familyId();
$rows = Movie::all($familyId); $keyword = trim((string)($_GET['keyword'] ?? ''));
$years = []; $rows = Movie::all($familyId, $keyword);
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', [ View::render('movies/index', [
'title' => '观影记录', 'title' => '观影记录',
'years' => $years, 'rows' => $rows,
'total' => count($rows), 'total' => count($rows),
'keyword' => $keyword,
], 'movies'); ], 'movies');
break; break;
@@ -1362,19 +1359,28 @@ switch ($r) {
flash('err', '找不到这条观影记录。'); flash('err', '找不到这条观影记录。');
redirect('movies'); redirect('movies');
} }
$meId = (int)Auth::personId();
$rvError = '';
View::render('movies/show', [ View::render('movies/show', [
'title' => (string)$m['title'], 'title' => (string)$m['title'],
'm' => $m, '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'); ], 'movies');
break; break;
case 'movies/new': case 'movies/new':
Auth::requireWrite(); Auth::requireWrite();
View::render('movies/form', [ View::render('movies/form', [
'title' => '记一场电影', 'title' => '记一场电影',
'm' => null, 'm' => null,
'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''), 'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''),
'errors' => [], 'cinemas' => Cinema::all((int)Auth::familyId()),
'errors' => [],
], 'movies'); ], 'movies');
break; break;
@@ -1387,10 +1393,11 @@ switch ($r) {
redirect('movies'); redirect('movies');
} }
View::render('movies/form', [ View::render('movies/form', [
'title' => '编辑观影记录', 'title' => '编辑观影记录',
'm' => $m, 'm' => $m,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => [], 'cinemas' => Cinema::all($familyId),
'errors' => [],
], 'movies'); ], 'movies');
break; break;
@@ -1410,18 +1417,22 @@ switch ($r) {
redirect('movies'); 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 = [ $d = [
'title' => trim((string)($_POST['title'] ?? '')), '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'] ?? '')), 'location' => trim((string)($_POST['location'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')),
'info' => trim((string)($_POST['info'] ?? '')), 'info' => trim((string)($_POST['info'] ?? '')),
'rating' => trim((string)($_POST['rating'] ?? '')), 'cinema_id' => $cinemaId,
'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 —— 表单可以改, // 同行的人:只认「确实存在且属于这个家」的 id —— 表单可以改,
// 不能凭它把别人家的成员挂进自己家的观影记录里(会顺着详情页漏人名)。 // 不能凭它把别人家的成员挂进自己家的观影记录里(会顺着详情页漏人名)。
'attendees' => array_map('intval', (array)($_POST['attendees'] ?? [])), 'attendees' => array_map('intval', (array)($_POST['attendees'] ?? [])),
@@ -1429,7 +1440,13 @@ switch ($r) {
$_familyPeople = Person::map($familyId); $_familyPeople = Person::map($familyId);
$d['attendees'] = array_values(array_filter($d['attendees'], $d['attendees'] = array_values(array_filter($d['attendees'],
static fn($pid) => isset($_familyPeople[$pid]))); 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 原图 // ⚠️ 图片上传失败**不挡保存**(与头像同一思路:一条记录因为一张 5MB 原图
// 存不进去,看起来像功能坏了)。现在是先存记录,图片的事单独提示。 // 存不进去,看起来像功能坏了)。现在是先存记录,图片的事单独提示。
@@ -1479,10 +1496,11 @@ switch ($r) {
if ($errors) { if ($errors) {
set_old($_POST); set_old($_POST);
View::render('movies/form', [ View::render('movies/form', [
'title' => $isEdit ? '编辑观影记录' : '记一场电影', 'title' => $isEdit ? '编辑观影记录' : '记一场电影',
'm' => $m, 'm' => $m,
'people' => Person::roster($familyId, $me, 'member', ''), 'people' => Person::roster($familyId, $me, 'member', ''),
'errors' => $errors, 'cinemas' => Cinema::all($familyId),
'errors' => $errors,
], 'movies'); ], 'movies');
exit; exit;
} }
@@ -1552,6 +1570,149 @@ switch ($r) {
redirect('movies'); redirect('movies');
break; 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: default:
http_response_code(404); http_response_code(404);
View::render('error', [ View::render('error', [
+11 -7
View File
@@ -37,21 +37,24 @@ $dtSec = $dtTs ? (int)date('s', $dtTs) : 0;
aria-label="日期"> aria-label="日期">
<span class="dt-time"> <span class="dt-time">
<select class="dt-hour" name="<?= e($dtName) ?>_hour" aria-label="时(24 小时制)"> <select class="dt-hour" name="<?= e($dtName) ?>_hour" aria-label="时(24 小时制)">
<?php for ($h = 0; $h < 24; $h++): $hv = sprintf('%02d', $h); ?> <?php for ($dtH = 0; $dtH < 24; $dtH++): $hv = sprintf('%02d', $dtH); ?>
<option value="<?= $hv ?>"<?= $h === $dtHour ? ' selected' : '' ?>><?= $hv ?></option> <option value="<?= $hv ?>"<?= $dtH === $dtHour ? ' selected' : '' ?>><?= $hv ?></option>
<?php endfor; ?> <?php endfor; ?>
</select> </select>
<span class="dt-colon" aria-hidden="true">:</span> <span class="dt-colon" aria-hidden="true">:</span>
<select class="dt-minute" name="<?= e($dtName) ?>_minute" aria-label="分"> <select class="dt-minute" name="<?= e($dtName) ?>_minute" aria-label="分">
<?php for ($m = 0; $m < 60; $m++): $mv = sprintf('%02d', $m); ?> <?php /* ⚠️ 循环变量一律带 dt 前缀:这里曾被写成 $m,把宿主模板里
<option value="<?= $mv ?>"<?= $m === $dtMin ? ' selected' : '' ?>><?= $mv ?></option> 表示「观影记录」的 $m 覆盖成了 int —— 编辑页的简介/备注回填
被静默清空、取消链接变 id=0,还不止一个 Warning(2026-09-30 e2e 抓到)。 */ ?>
<?php for ($dtM = 0; $dtM < 60; $dtM++): $mv = sprintf('%02d', $dtM); ?>
<option value="<?= $mv ?>"<?= $dtM === $dtMin ? ' selected' : '' ?>><?= $mv ?></option>
<?php endfor; ?> <?php endfor; ?>
</select> </select>
<?php if ($dtSeconds): ?> <?php if ($dtSeconds): ?>
<span class="dt-colon" aria-hidden="true">:</span> <span class="dt-colon" aria-hidden="true">:</span>
<select class="dt-second" name="<?= e($dtName) ?>_second" aria-label="秒"> <select class="dt-second" name="<?= e($dtName) ?>_second" aria-label="秒">
<?php for ($s = 0; $s < 60; $s++): $sv = sprintf('%02d', $s); ?> <?php for ($dtS = 0; $dtS < 60; $dtS++): $sv = sprintf('%02d', $dtS); ?>
<option value="<?= $sv ?>"<?= $s === $dtSec ? ' selected' : '' ?>><?= $sv ?></option> <option value="<?= $sv ?>"<?= $dtS === $dtSec ? ' selected' : '' ?>><?= $sv ?></option>
<?php endfor; ?> <?php endfor; ?>
</select> </select>
<?php endif; ?> <?php endif; ?>
@@ -59,5 +62,6 @@ $dtSec = $dtTs ? (int)date('s', $dtTs) : 0;
</div> </div>
<?php /* 用完即清,避免残留变量影响下一次 include */ unset( <?php /* 用完即清,避免残留变量影响下一次 include */ unset(
$dtName, $dtValue, $dtRequired, $dtId, $dtSeconds, $dtName, $dtValue, $dtRequired, $dtId, $dtSeconds,
$dtTs, $dtDate, $dtHour, $dtMin, $dtSec $dtTs, $dtDate, $dtHour, $dtMin, $dtSec,
$dtH, $dtM, $dtS, $hv, $mv, $sv
); ?> ); ?>
+2 -1
View File
@@ -7,6 +7,7 @@ $navItems = [
'kin' => ['家谱', 'kin'], 'kin' => ['家谱', 'kin'],
'people' => ['名册', 'people'], 'people' => ['名册', 'people'],
'movies' => ['观影', 'movies'], 'movies' => ['观影', 'movies'],
'cinemas' => ['影院', 'movies/cinemas'],
]; ];
// ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的, // ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的,
// 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。 // 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。
@@ -37,7 +38,7 @@ $isLoggedIn = \App\Auth::check();
<span>家书</span> <span>家书</span>
</a> </a>
<nav class="nav"> <nav class="nav">
<?php foreach ($navItems as $route => list($label, $key)): ?> <?php foreach ($navItems as $key => list($label, $route)): ?>
<a class="<?= $pageKey === $key ? 'on' : '' ?>" href="<?= e(url($route)) ?>"><?= e($label) ?></a> <a class="<?= $pageKey === $key ? 'on' : '' ?>" href="<?= e(url($route)) ?>"><?= e($label) ?></a>
<?php endforeach; ?> <?php endforeach; ?>
</nav> </nav>
+136
View File
@@ -0,0 +1,136 @@
<?php
/**
* 影院管理(v9,MovieLog cinemas.php 同款):上面加/编表单,下面列表。
*
* 颜色用「取色器 + 50 色预设板」两路输入(内联小段 JS 只是取色联动,
* 不依赖 JS 也能手填 hex 或直接用取色器)。删除是 POST 小表单 + confirm;
* 删掉的影院只把关联观影记录的 cinema_id 置空,记录本身不动。
*/
$editing = !empty($editing);
$canWrite = \App\Auth::canWrite();
?>
<div class="page-head">
<h1>影院管理</h1>
<span class="sub">共 <?= count($cinemas) ?> 家影院</span>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies')) ?>">返回观影记录</a>
</div>
</div>
<?php if (!$canWrite): ?>
<div class="card card-pad empty"><b>只读账号</b>访客只能查看影院列表。</div>
<?php else: ?>
<div class="card card-pad cinema-form-card">
<h2 class="cinema-h2"><?= $editing ? '编辑影院' : '添加影院' ?></h2>
<form method="post" action="<?= e(url('movies/cinemas_save')) ?>">
<?= csrf_field() ?>
<?php if ($editing): ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<div class="field">
<label for="cname">影院名称 *</label>
<input class="input" id="cname" name="name" required
value="<?= e(old('name', $editing['name'] ?? '')) ?>" placeholder="如:万达影城(IMAX 店)">
</div>
<div class="field">
<label for="cplace">所在地方</label>
<input class="input" id="cplace" name="place"
value="<?= e(old('place', $editing['place'] ?? '')) ?>" placeholder="如:青岛 · 崂山区">
</div>
<div class="field full">
<label for="caddress">详细地址</label>
<input class="input" id="caddress" name="address"
value="<?= e(old('address', $editing['address'] ?? '')) ?>">
</div>
<div class="field full">
<label for="ccolor">主题颜色</label>
<div class="color-row">
<input type="color" id="ccolor" name="color" class="color-picker"
value="<?= e(old('color', $editing['color'] ?? '#888888')) ?>">
<span class="color-hex" id="colorHex"><?= e(old('color', $editing['color'] ?? '#888888')) ?></span>
<span class="preset-palette">
<?php foreach (\App\Cinema::PRESET_COLORS as $pc): ?>
<span class="preset-color" data-color="<?= $pc ?>" style="background: <?= $pc ?>;" title="<?= $pc ?>"></span>
<?php endforeach; ?>
</span>
</div>
</div>
</div>
<div class="btn-row">
<button type="submit" class="btn"><?= $editing ? '保存' : '添加影院' ?></button>
<?php if ($editing): ?>
<a class="btn btn-ghost" href="<?= e(url('movies/cinemas')) ?>">取消编辑</a>
<?php endif; ?>
</div>
</form>
</div>
<?php endif; ?>
<div class="card card-pad">
<h2 class="cinema-h2">影院列表</h2>
<?php if (!$cinemas): ?>
<p class="empty-hint">还没有影院,先在上面添加一家。</p>
<?php else: ?>
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>名称</th><th>所在地方</th><th>详细地址</th><th>观影</th><?php if ($canWrite): ?><th>操作</th><?php endif; ?></tr>
</thead>
<tbody>
<?php foreach ($cinemas as $c): ?>
<tr>
<td>
<span class="color-preview" style="background: <?= e((string)($c['color'] ?: '#888888')) ?>;"></span>
<b><?= e($c['name']) ?></b>
</td>
<td><?= e((string)($c['place'] ?? '')) !== '' ? e($c['place']) : '—' ?></td>
<td><?= !empty($c['address']) ? e($c['address']) : '—' ?></td>
<td><?= (int)$c['movie_count'] ?> 场</td>
<?php if ($canWrite): ?>
<td>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies/cinemas', ['edit' => (int)$c['id']])) ?>">编辑</a>
<form method="post" action="<?= e(url('movies/cinemas_del')) ?>"
onsubmit="return confirm('确定删除「<?= e($c['name']) ?>」吗?关联观影记录的影院信息将置空。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<button type="submit" class="btn btn-danger btn-sm">删除</button>
</form>
</div>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php if ($canWrite): ?>
<script>
(function () {
var picker = document.getElementById('ccolor');
var hex = document.getElementById('colorHex');
if (!picker || !hex) return;
function setActive(color) {
Array.prototype.forEach.call(document.querySelectorAll('.preset-color'), function (el) {
el.classList.toggle('active', el.getAttribute('data-color') === color);
});
}
picker.addEventListener('input', function () {
hex.textContent = picker.value;
setActive(picker.value);
});
Array.prototype.forEach.call(document.querySelectorAll('.preset-color'), function (el) {
el.addEventListener('click', function () {
picker.value = el.getAttribute('data-color');
hex.textContent = picker.value;
setActive(picker.value);
});
});
setActive(picker.value);
})();
</script>
<?php endif; ?>
+43 -60
View File
@@ -2,24 +2,31 @@
/** /**
* 观影表单(新增 / 编辑,一张表单两用 —— 与 people/form 同一思路)。 * 观影表单(新增 / 编辑,一张表单两用 —— 与 people/form 同一思路)。
* *
* 字段分三块:看什么(片名/日期/评分/感受)、跟谁(同行的人)、在哪看(影院三段), * v9 变化:观影时间改为「开始 / 结束」两个秒级时刻(_datetime.php 控件,
* 再加自由文本(简介/观后感/备注)。海报与票根是 free 槽位(v8): * 服务端 read_dt_input 取值);评分与观后感**移出表单** —— 搬到详情页的
* 不进裁剪器、整图等比缩进框内,所以这里不放裁剪框,只放普通文件控件。 * 「评价与评论」区(MovieLog 同款:每账号一条评价,谁都能评);影院改为
* 下拉选择已有影院 + 可选「新建影院」行内三段。
* *
* 现场照片只在**新增**时提供多选;编辑时在详情页里逐张补/删 —— * 海报与票根是 free 槽位(v8):不进裁剪器、整图等比缩进框内,普通文件控件即可。
* 一次改一处的表单,比一个巨型表单不容易出错。 * 现场照片只在**新增**时提供多选;编辑时在详情页里逐张补/删。
*/ */
$isEdit = !empty($m['id']); $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')); $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)); $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
$selFeelings = (array)old('feelings', $curFeelings);
$rtVal = old('rating', $m['rating'] ?? '');
?> ?>
<div class="page-head"> <div class="page-head">
<h1><?= $isEdit ? '编辑观影记录' : '记一场电影' ?></h1> <h1><?= $isEdit ? '编辑观影记录' : '记一场电影' ?></h1>
@@ -41,34 +48,34 @@ $rtVal = old('rating', $m['rating'] ?? '');
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?> <?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div> </div>
<div class="field"> <div class="field full">
<label for="starts_at">观影日期</label> <label <?= $isEdit ? '' : 'for="starts_at_date"' ?>>开始时间(精确到秒)</label>
<input class="input" id="starts_at" name="starts_at" type="date" <?php $dtName = 'starts_at'; $dtValue = $dtStart; $dtId = 'starts_at_date';
value="<?= e(old('starts_at', $m['starts_at'] ?? '')) ?>"> require __DIR__ . '/../_datetime.php'; ?>
</div> <?php if (!empty($errors['starts_at'])): ?><span class="err"><?= e($errors['starts_at']) ?></span><?php endif; ?>
<div class="field">
<label for="rating">评分(1-10)</label>
<select class="select" id="rating" name="rating">
<option value="">不评</option>
<?php for ($i = 10; $i >= 1; $i--): ?>
<option value="<?= $i ?>" <?= (string)$rtVal === (string)$i ? 'selected' : '' ?>><?= $i ?></option>
<?php endfor; ?>
</select>
</div> </div>
<div class="field full"> <div class="field full">
<label>观后感受</label> <label>结束时间(散场,可不填)</label>
<div class="feel-grid"> <?php $dtName = 'ends_at'; $dtValue = $dtEnd; $dtId = '';
<?php foreach (\App\Movie::FEELINGS as $f): ?> require __DIR__ . '/../_datetime.php'; ?>
<label class="feel-item"> <?php if (!empty($errors['ends_at'])): ?><span class="err"><?= e($errors['ends_at']) ?></span><?php endif; ?>
<input type="checkbox" name="feelings[]" value="<?= e($f) ?>" </div>
<?= in_array($f, $selFeelings, true) ? 'checked' : '' ?>>
<span><?= e($f) ?></span> <div class="field full">
</label> <label for="cinema_id">影院</label>
<?php endforeach; ?> <div class="cinema-select-row">
<select class="select" id="cinema_id" name="cinema_id">
<option value="0">不记录影院</option>
<?php foreach ($cinemas as $c): ?>
<option value="<?= (int)$c['id'] ?>" <?= $selCinema === (int)$c['id'] ? 'selected' : '' ?>>
<?= e(trim(((string)($c['place'] ?? '') !== '' ? $c['place'] . ' · ' : '') . $c['name'])) ?>
</option>
<?php endforeach; ?>
</select>
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies/cinemas')) ?>">管理影院</a>
</div> </div>
<span class="tip">MovieLog 里记的英文标签(warm / excited…)搬过来时已经翻成中文。</span> <span class="tip">想记一家还没录过的影院?先去「管理影院」里添加,回来这里就能选。</span>
</div> </div>
<div class="field full"> <div class="field full">
@@ -88,36 +95,12 @@ $rtVal = old('rating', $m['rating'] ?? '');
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="field">
<label for="cinema_place">影院 · 城市/商圈</label>
<input class="input" id="cinema_place" name="cinema_place"
value="<?= e(old('cinema_place', $m['cinema_place'] ?? '')) ?>" placeholder="如:青岛 · 崂山区">
</div>
<div class="field">
<label for="cinema_name">影院 · 名字</label>
<input class="input" id="cinema_name" name="cinema_name"
value="<?= e(old('cinema_name', $m['cinema_name'] ?? '')) ?>" placeholder="如:万达影城 CBD 店">
</div>
<div class="field full">
<label for="cinema_address">影院 · 地址</label>
<input class="input" id="cinema_address" name="cinema_address"
value="<?= e(old('cinema_address', $m['cinema_address'] ?? '')) ?>">
</div>
<div class="field full"> <div class="field full">
<label for="info">简介</label> <label for="info">简介</label>
<textarea class="input" id="info" name="info" rows="3" <textarea class="input" id="info" name="info" rows="3"
placeholder="剧情简介(可选)"><?= e(old('info', $m['info'] ?? '')) ?></textarea> placeholder="剧情简介(可选)"><?= e(old('info', $m['info'] ?? '')) ?></textarea>
</div> </div>
<div class="field full">
<label for="review">观后感</label>
<textarea class="input" id="review" name="review" rows="4"
placeholder="看完想记下来的话"><?= e(old('review', $m['review'] ?? '')) ?></textarea>
</div>
<div class="field full"> <div class="field full">
<label for="note">备注</label> <label for="note">备注</label>
<textarea class="input" id="note" name="note" rows="2"><?= e(old('note', $m['note'] ?? '')) ?></textarea> <textarea class="input" id="note" name="note" rows="2"><?= e(old('note', $m['note'] ?? '')) ?></textarea>
+58 -40
View File
@@ -1,61 +1,79 @@
<?php <?php
/** /**
* 观影时间线 —— 按年分组倒序(v8,MovieLog 迁入后的新家)。 * 观影瀑布流(v9,MovieLog 同款海报墙)。
* *
* 没填日期的记录落在键 0,沉到最底下单独一组「日期待补」, * 卡片 = 海报(2:3,无海报给渐变占位)+ 片名 + 日期/影院(主题色)/评分均分 + 同行人头像标签。
* 不让它混进任何一个年份里(跟名册「未分类」栏是同一个思路)。 * 顶部搜索栏:关键词搜片名 / 影院 / 同行人(Movie::all 的 LIKE 查询)。
*/ */
$canWrite = \App\Auth::canWrite(); $canWrite = \App\Auth::canWrite();
$fmtNum = static function ($v): string {
if ($v === null) {
return '';
}
return rtrim(rtrim(number_format((float)$v, 1, '.', ''), '0'), '.');
};
?> ?>
<div class="page-head"> <div class="page-head">
<h1>观影记录</h1> <h1>观影记录</h1>
<span class="sub">一共 <?= (int)$total ?> 场</span> <span class="sub">
<?php if ($keyword !== ''): ?>
找到 <?= (int)$total ?> 部匹配「<b><?= e($keyword) ?></b>」的电影
<?php else: ?>
共 <?= (int)$total ?> 部 · 按观影时间排序
<?php endif; ?>
</span>
<div class="actions"> <div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies/cinemas')) ?>">影院管理</a>
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<a class="btn" href="<?= e(url('movies/new')) ?>">记一场电影</a> <a class="btn" href="<?= e(url('movies/new')) ?>">记一场电影</a>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
<form method="get" action="index.php" class="search-bar">
<input type="hidden" name="r" value="movies">
<input type="text" name="keyword" value="<?= e($keyword) ?>" placeholder="搜索片名、影院、观影人…" aria-label="搜索">
<button type="submit">搜索</button>
<?php if ($keyword !== ''): ?>
<a class="search-clear" href="<?= e(url('movies')) ?>">清除</a>
<?php endif; ?>
</form>
<?php if ($total === 0): ?> <?php if ($total === 0): ?>
<div class="card card-pad empty"> <div class="card card-pad empty">
<b>还没有观影记录</b> <b><?= $keyword !== '' ? '没有找到匹配的电影' : '还没有观影记录' ?></b>
一家人一起看过的电影,都会按年份排在这里。 <?= $keyword !== '' ? '换个关键词试试。' : '一家人一起看过的电影,都会以海报墙的方式排在这里。' ?>
</div> </div>
<?php else: ?> <?php else: ?>
<?php foreach ($years as $y => $list): ?> <div class="waterfall">
<section class="section"> <?php foreach ($rows as $mv): ?>
<h2 class="section-title"><?= $y > 0 ? (int)$y : '日期待补' ?> <span class="hint"><?= count($list) ?> 场</span></h2> <a class="movie-card" href="<?= e(url('movies/show', ['id' => (int)$mv['id']])) ?>">
<div class="movie-list"> <span class="card-image">
<?php foreach ($list as $mv): ?> <?php if (!empty($mv['poster_key'])): ?>
<?php <img src="<?= e(media_url($mv['poster_key'])) ?>" alt="<?= e($mv['title']) ?>" loading="lazy">
// 评分统一一位小数内显示:9 → "9",8.5 → "8.5"(库里是 0-10 整数,这里只做展示兜底) <?php else: ?>
$rtTxt = $mv['rating'] !== null <span class="placeholder-poster">
? rtrim(rtrim(number_format((float)$mv['rating'], 1, '.', ''), '0'), '.') <span class="ph-icon" aria-hidden="true">影</span>
: ''; <span class="placeholder-title"><?= e($mv['title']) ?></span>
?>
<a class="movie-card" href="<?= e(url('movies/show', ['id' => (int)$mv['id']])) ?>">
<?php if (!empty($mv['poster_key'])): ?>
<img class="m-poster" src="<?= e(media_url($mv['poster_key'])) ?>" alt="" loading="lazy">
<?php else: ?>
<span class="m-poster m-poster-empty" aria-hidden="true">影</span>
<?php endif; ?>
<span class="m-main">
<span class="m-title">
<?= e($mv['title']) ?>
<?php if ($rtTxt !== ''): ?><span class="rating"><?= e($rtTxt) ?></span><?php endif; ?>
</span>
<span class="m-meta">
<?= e((string)($mv['starts_at'] ?? '日期待补')) ?>
<?php if (!empty($mv['cinema_name'])): ?> · <?= e($mv['cinema_name']) ?><?php endif; ?>
</span>
<?php if (!empty($mv['attendee_names'])): ?>
<span class="m-who">和 <?= e($mv['attendee_names']) ?></span>
<?php endif; ?>
</span> </span>
</a> <?php endif; ?>
<?php endforeach; ?> </span>
</div> <span class="card-body">
</section> <span class="card-title"><?= e($mv['title']) ?></span>
<?php endforeach; ?> <span class="card-meta">
<span class="meta-date">📅 <?= e(\App\Movie::formatWatch($mv['starts_at'], $mv['ends_at'])) ?></span>
<?php if (!empty($mv['cinema_name'])): ?>
<span class="meta-cinema" style="color: <?= e((string)($mv['cinema_color'] ?: '#888888')) ?>;">🏛️ <?= e($mv['cinema_name']) ?></span>
<?php endif; ?>
<?php if (!empty($mv['review_count'])): ?>
<span class="meta-rating" title="共 <?= (int)$mv['review_count'] ?> 条评价">⭐ <?= e($fmtNum($mv['review_avg'])) ?><span class="meta-rating-max">/10</span></span>
<?php endif; ?>
</span>
<?php if (!empty($mv['attendee_names'])): ?>
<span class="card-viewers"><span class="viewer-tag">👥 <?= e($mv['attendee_names']) ?></span></span>
<?php endif; ?>
</span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?> <?php endif; ?>
+432 -77
View File
@@ -1,25 +1,126 @@
<?php <?php
/** /**
* 观影详情页。 * 观影详情页(v9,展示方式照搬 MovieLog 的 detail.php)。
* *
* 海报 / 票根 / 照片一律走 media_url() 代理(全站唯一出口,见 helpers.php); * 五大块:英雄区(左海报右信息)→ 一起看的人 → 电影院信息 → 影像记录(灯箱)→ 评价与评论。
* 感受标签是迁移时已翻成中文的 JSON 数组,这里只展示不加工。 * 海报/票根/照片一律走 media_url() 代理;感受标签只存 key,emoji 与文案查 CATALOG。
* 访客(只读)看不到编辑/删除/传照片那些表单 —— 与名册同一口径。 * 访客(只读)看不到编辑/删除/传照片/评价表单 —— 与名册同一口径。
* 评价人是「登录账号背后的人」,与同行观影人无关(MovieLog 同款规则)。
*/ */
$canWrite = \App\Auth::canWrite(); $canWrite = \App\Auth::canWrite();
$feel = [];
if (!empty($m['feelings'])) {
$feel = json_decode((string)$m['feelings'], true);
if (!is_array($feel)) {
$feel = [];
}
}
$hasPoster = !empty($m['poster_key']); $hasPoster = !empty($m['poster_key']);
$hasTicket = !empty($m['ticket_key']); $hasTicket = !empty($m['ticket_key']);
$watchTxt = \App\Movie::formatWatch($m['starts_at'], $m['ends_at'] ?? null);
$fmtNum = static function ($v): string {
if ($v === null) {
return '';
}
return rtrim(rtrim(number_format((float)$v, 1, '.', ''), '0'), '.');
};
/** 头像或首字占位(persons.avatar 是存储相对键) */
$avatarHtml = static function (?string $avatar, string $name, string $cls = '') {
if ($avatar !== null && $avatar !== '') {
return '<img class="rv-avatar ' . e($cls) . '" src="' . e(media_url($avatar)) . '" alt="">';
}
return '<span class="rv-avatar rv-avatar-text ' . e($cls) . '">' . e(mb_substr($name, 0, 1, 'UTF-8')) . '</span>';
};
/** 可勾选的感受 chips(原生 checkbox,外观在 .feel-inner 上) */
$feelPick = static function (array $checked = []) use ($feelCat) {
ob_start(); ?>
<span class="feel-chips">
<?php foreach ($feelCat as $k => $f): ?>
<label class="feel-chip feel-pick" title="<?= e($f['label']) ?>">
<input type="checkbox" name="feelings[]" value="<?= e($k) ?>"<?= in_array($k, $checked, true) ? ' checked' : '' ?>>
<span class="feel-inner"><span class="feel-emoji"><?= $f['emoji'] ?></span><span class="feel-label"><?= e($f['label']) ?></span></span>
</label>
<?php endforeach; ?>
</span>
<?php return ob_get_clean();
};
/** 只读的感受标签 */
$feelTags = static function (array $keys) use ($feelCat) {
if (!$keys) {
return '';
}
ob_start(); ?>
<span class="rv-feelings">
<?php foreach ($keys as $k): ?>
<?php if (!isset($feelCat[$k])) { continue; } $f = $feelCat[$k]; ?>
<span class="feel-chip is-static" title="观后感受:<?= e($f['label']) ?>">
<span class="feel-emoji"><?= $f['emoji'] ?></span><span class="feel-label"><?= e($f['label']) ?></span>
</span>
<?php endforeach; ?>
</span>
<?php return ob_get_clean();
};
/** 我的评价表单(打分 + 正文 + 感受),details 按需展开;写评价与修改共用这一份 */
$rvForm = static function (string $summary, bool $open) use ($m, $myReview, $feelPick, $rvError) {
ob_start(); ?>
<details class="rv-composer"<?= $open ? ' open' : '' ?>>
<summary class="rv-open-btn"><?= e($summary) ?></summary>
<form method="post" action="<?= e(url('movies/review_save')) ?>" class="rv-form" id="reviewForm">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<div class="rv-rating-row">
<span class="rv-rating-label">我的评分</span>
<span class="rv-scale" role="radiogroup" aria-label="评分,1 分到 10 分">
<?php $myRating = (int)($myReview['rating'] ?? 0); ?>
<?php for ($i = 1; $i <= 10; $i++): ?>
<label class="rv-num" title="<?= $i ?> 分">
<input type="radio" name="rating" value="<?= $i ?>"<?= $myRating === $i ? ' checked' : '' ?>>
<span><?= $i ?></span>
</label>
<?php endfor; ?>
</span>
<span class="rv-word<?= $myRating > 0 ? ' is-set' : '' ?>" id="rvWord">
<?= $myRating > 0 ? $myRating . ' 分 · ' . e(\App\MovieReview::ratingWord($myRating)) : '未评分' ?>
</span>
</div>
<textarea name="content" class="rv-textarea" maxlength="<?= (int)\App\MovieReview::CONTENT_MAX ?>" required
placeholder="这部电影怎么样?剧情、表演、画面、音效……随便聊聊"><?= e((string)($myReview['content'] ?? '')) ?></textarea>
<div class="rv-feeling-pick">
<span class="rv-pick-label">我看完的感受(可多选)</span>
<?= $feelPick((array)($myReview['feelings'] ?? [])) ?>
</div>
<div class="rv-form-foot">
<span class="rv-hint">每个账号对每部电影只有一条评价,再次提交即为修改。</span>
<button type="submit" class="btn btn-sm"><?= $myReview ? '保存修改' : '发表评价' ?></button>
</div>
</form>
</details>
<?php return ob_get_clean();
};
/** 评论 / 回复表单(parent_id=0 是对评价发评论;>0 是回复某条评论) */
$commentBox = static function (int $reviewId, int $parentId, string $summary) use ($m, $feelPick) {
ob_start(); ?>
<details class="rv-composer rv-inline">
<summary class="rv-act-btn"><?= e($summary) ?></summary>
<form method="post" action="<?= e(url('movies/comment_add')) ?>" class="rv-form rv-form-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="review_id" value="<?= $reviewId ?>">
<input type="hidden" name="parent_id" value="<?= $parentId ?>">
<textarea name="content" class="rv-textarea rv-textarea-sm" maxlength="<?= (int)\App\MovieReview::COMMENT_MAX ?>" required
placeholder="<?= $parentId > 0 ? '回复这一条…' : '说说你的看法…' ?>"></textarea>
<div class="rv-feeling-pick">
<span class="rv-pick-label">观后感受(可多选)</span>
<?= $feelPick() ?>
</div>
<div class="rv-form-foot">
<button type="submit" class="btn btn-ghost btn-sm"><?= $parentId > 0 ? '发表回复' : '发表评论' ?></button>
</div>
</form>
</details>
<?php return ob_get_clean();
};
?> ?>
<div class="page-head"> <div class="page-head">
<h1><?= e($m['title']) ?></h1> <span class="sub">观影记录</span>
<span class="sub"><?= e((string)($m['starts_at'] ?? '日期待补')) ?></span>
<div class="actions"> <div class="actions">
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies/edit', ['id' => (int)$m['id']])) ?>">编辑</a> <a class="btn btn-ghost btn-sm" href="<?= e(url('movies/edit', ['id' => (int)$m['id']])) ?>">编辑</a>
@@ -30,80 +131,90 @@ $hasTicket = !empty($m['ticket_key']);
<button type="submit" class="btn btn-danger btn-sm">删除</button> <button type="submit" class="btn btn-danger btn-sm">删除</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies')) ?>">返回</a> <a class="btn btn-ghost btn-sm" href="<?= e(url('movies')) ?>">返回列表</a>
</div> </div>
</div> </div>
<div class="movie-detail"> <!-- 主信息:英雄区 -->
<div class="m-poster-col"> <div class="detail-hero">
<div class="detail-poster<?= $hasPoster ? ' lb-openable' : '' ?>"
<?php if ($hasPoster): ?>data-lb-index="0" role="button" tabindex="0" aria-label="放大查看海报" title="点击查看大图"<?php endif; ?>>
<?php if ($hasPoster): ?> <?php if ($hasPoster): ?>
<a href="<?= e(media_url($m['poster_key'])) ?>" target="_blank" rel="noopener"> <img src="<?= e(media_url($m['poster_key'])) ?>" alt="海报">
<img class="m-poster-lg" src="<?= e(media_url($m['poster_key'])) ?>" alt="海报">
</a>
<?php else: ?> <?php else: ?>
<span class="m-poster-lg m-poster-empty" aria-hidden="true">影</span> <span class="placeholder-poster">
<?php endif; ?> <span class="ph-icon" aria-hidden="true">影</span>
<?php if ($hasTicket): ?> <span class="placeholder-title"><?= e($m['title']) ?></span>
<a href="<?= e(media_url($m['ticket_key'])) ?>" target="_blank" rel="noopener"> </span>
<img class="m-ticket" src="<?= e(media_url($m['ticket_key'])) ?>" alt="票根" loading="lazy">
</a>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="detail-info">
<div class="card card-pad m-info"> <h1><?= e($m['title']) ?></h1>
<?php if ($m['rating'] !== null || $feel): ?> <div class="meta-row">
<div class="m-score"> <span>📅 <?= e($watchTxt) ?></span>
<?php if ($m['rating'] !== null): ?> <?php if (!empty($m['cinema_name'])): ?>
<span class="rating rating-lg"><?= e(rtrim(rtrim(number_format((float)$m['rating'], 1, '.', ''), '0'), '.')) ?></span> <span style="color: <?= e((string)($m['cinema_color'] ?: '#888888')) ?>;">🏛️ <?= e($m['cinema_name']) ?></span>
<?php endif; ?>
<?php foreach ($feel as $f): ?>
<span class="badge badge-feel"><?= e($f) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<dl class="kv">
<div class="kv-row"><dt>观影日期</dt><dd><?= e((string)($m['starts_at'] ?? '日期待补')) ?></dd></div>
<?php if (!empty($m['cinema_place']) || !empty($m['cinema_name']) || !empty($m['cinema_address'])): ?>
<div class="kv-row"><dt>影院</dt><dd>
<?= e(trim(implode(' ', array_filter([
(string)($m['cinema_place'] ?? ''),
(string)($m['cinema_name'] ?? ''),
(string)($m['cinema_address'] ?? ''),
], static fn($s) => $s !== '')))) ?>
</dd></div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($m['location'])): ?> <span>👥 观影人 <?= count($m['attendees']) ?> 人</span>
<div class="kv-row"><dt>地点</dt><dd><?= e($m['location']) ?></dd></div> </div>
<?php endif; ?>
<div class="kv-row"><dt>同行的人</dt><dd>
<?php if ($m['attendees']): ?>
<?php foreach ($m['attendees'] as $i => $a): ?><?= $i > 0 ? '、' : '' ?><a href="<?= e(url('people/show', ['id' => (int)$a['id']])) ?>"><?= e($a['name']) ?></a><?php endforeach; ?>
<?php else: ?>一个人看的<?php endif; ?>
</dd></div>
</dl>
<?php if (!empty($m['info'])): ?> <?php if (!empty($m['info'])): ?>
<div class="m-block"><h3>简介</h3><p><?= nl2br(e($m['info'])) ?></p></div> <div class="info-text"><?= nl2br(e($m['info'])) ?></div>
<?php endif; ?>
<?php if (!empty($m['review'])): ?>
<div class="m-block"><h3>观后感</h3><p><?= nl2br(e($m['review'])) ?></p></div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($m['note'])): ?> <?php if (!empty($m['note'])): ?>
<div class="m-block"><h3>备注</h3><p><?= nl2br(e($m['note'])) ?></p></div> <div class="remark-text">📝 <?= nl2br(e($m['note'])) ?></div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
<?php if ($m['photos']): ?> <!-- 一起看的人 -->
<section class="section"> <div class="detail-section">
<h2 class="section-title">现场照片 <span class="hint"><?= count($m['photos']) ?> 张</span></h2> <h3>一起看的人 <span class="badge"><?= count($m['attendees']) ?> 人</span></h3>
<div class="photo-grid"> <?php if ($m['attendees']): ?>
<?php foreach ($m['photos'] as $ph): ?> <div class="viewers-list">
<div class="photo-cell"> <?php foreach ($m['attendees'] as $a): ?>
<a href="<?= e(media_url($ph['key'])) ?>" target="_blank" rel="noopener"> <a class="viewer-tag" href="<?= e(url('people/show', ['id' => (int)$a['id']])) ?>">
<img src="<?= e(media_url($ph['key'])) ?>" alt="" loading="lazy"> <?= $avatarHtml($a['avatar'], (string)$a['name']) ?>
</a> <?= e($a['name']) ?>
</a>
<?php endforeach; ?>
</div>
<?php else: ?>
<p class="empty-hint">一个人看的</p>
<?php endif; ?>
</div>
<!-- 电影院信息 -->
<div class="detail-section">
<h3>电影院信息</h3>
<?php if (!empty($m['cinema_name'])): ?>
<div class="cinema-info">
<span class="label">所在地方</span>
<span class="value"><?= e((string)($m['cinema_place'] ?? '-')) ?></span>
<span class="label">影院名称</span>
<span class="value" style="color: <?= e((string)($m['cinema_color'] ?: '#888888')) ?>;"><?= e($m['cinema_name']) ?></span>
<span class="label">详细地址</span>
<span class="value"><?= e((string)($m['cinema_address'] ?? '-')) ?></span>
</div>
<?php else: ?>
<p class="empty-hint">未记录电影院信息</p>
<?php endif; ?>
</div>
<!-- 影像记录(票根 + 现场照片,点开灯箱) -->
<?php if ($hasTicket || $m['photos']): ?>
<div class="detail-section">
<h3>影像记录 <span class="badge"><?= count($m['photos']) + ($hasTicket ? 1 : 0) ?> 张</span></h3>
<div class="gallery-grid">
<?php if ($hasTicket): ?>
<div class="gallery-item lb-openable" data-lb-index="1" role="button" tabindex="0" aria-label="放大查看票根" title="点击查看大图">
<img src="<?= e(media_url($m['ticket_key'])) ?>" alt="票根" loading="lazy">
<span class="label-badge">票根</span>
</div>
<?php endif; ?>
<?php foreach ($m['photos'] as $i => $ph): ?>
<div class="gallery-item lb-openable" data-lb-index="<?= $i + 2 ?>" role="button" tabindex="0" aria-label="放大查看照片" title="点击查看大图">
<img src="<?= e(media_url((string)$ph['key'])) ?>" alt="" loading="lazy">
<span class="label-badge">照片</span>
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<form method="post" action="<?= e(url('movies/photo_del')) ?>" <form method="post" action="<?= e(url('movies/photo_del')) ?>"
onsubmit="return confirm('删除这张照片?')"> onsubmit="return confirm('删除这张照片?')">
@@ -116,12 +227,11 @@ $hasTicket = !empty($m['ticket_key']);
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</section> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<section class="section"> <div class="detail-section">
<form method="post" enctype="multipart/form-data" action="<?= e(url('movies/photo_add')) ?>" class="card card-pad photo-add"> <form method="post" enctype="multipart/form-data" action="<?= e(url('movies/photo_add')) ?>" class="photo-add">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<label for="photo">补一张现场照片</label> <label for="photo">补一张现场照片</label>
@@ -130,5 +240,250 @@ $hasTicket = !empty($m['ticket_key']);
<button type="submit" class="btn btn-ghost btn-sm">上传</button> <button type="submit" class="btn btn-ghost btn-sm">上传</button>
</div> </div>
</form> </form>
</section> </div>
<?php endif; ?>
<!-- 评价与评论 -->
<div class="detail-section" id="reviews">
<h3>评价与评论
<span class="badge">
<?php if ($rvStats['count'] > 0): ?>
<?= (int)$rvStats['count'] ?> 条评价 · 均分 <?= e($fmtNum($rvStats['avg'])) ?>
<?php else: ?>
还没有评价
<?php endif; ?>
</span>
</h3>
<?php if ($canWrite && !$myReview): ?>
<?= $rvForm('写评价', $rvError !== '') ?>
<?php endif; ?>
<?php if (!$reviews): ?>
<p class="empty-hint">还没有评价,等你来评一个。</p>
<?php else: ?>
<div class="rv-list">
<?php foreach ($reviews as $rv): ?>
<?php
$rvMine = ((int)$rv['person_id'] === $meId);
$rvName = (string)($rv['person_name'] ?? '已注销');
$rvRate = (int)$rv['rating'];
?>
<div class="rv-item">
<div class="rv-head">
<?= $avatarHtml($rv['person_avatar'] ?? null, $rvName) ?>
<span class="rv-who">
<span class="rv-name"><?= e($rvName) ?></span>
<?php if ($rvMine): ?><span class="rv-mine">我</span><?php endif; ?>
<span class="rv-time"><?= e(substr((string)$rv['created_at'], 0, 16)) ?></span>
</span>
<span class="rv-score" title="<?= e(\App\MovieReview::ratingWord($rvRate)) ?>"><?= $rvRate ?><span class="rv-score-max">/10</span></span>
<?php if ($rvMine && $canWrite): ?>
<form method="post" action="<?= e(url('movies/review_del')) ?>" class="rv-del"
onsubmit="return confirm('删除我的这条评价?下面的评论也会一起删除,不可恢复。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="review_id" value="<?= (int)$rv['id'] ?>">
<button type="submit" class="rv-del-btn" title="删除我的评价">×</button>
</form>
<?php endif; ?>
</div>
<div class="rv-content"><?= nl2br(e($rv['content'])) ?></div>
<?= $feelTags($rv['feelings']) ?>
<?php if (!empty($rv['updated_at']) && $rv['updated_at'] !== $rv['created_at']): ?>
<div class="rv-updated">已编辑 <?= e(substr((string)$rv['updated_at'], 0, 16)) ?></div>
<?php endif; ?>
<?php if (!empty($rv['comments'])): ?>
<div class="rv-comments">
<?php foreach ($rv['comments'] as $thread): ?>
<?php
$c = $thread['comment'];
$cMine = ((int)$c['person_id'] === $meId);
$cName = (string)($c['person_name'] ?? '已注销');
?>
<div class="rv-thread">
<div class="rv-comment">
<?= $avatarHtml($c['person_avatar'] ?? null, $cName, 'rv-avatar-sm') ?>
<span class="rv-c-body">
<span class="rv-c-head">
<span class="rv-name"><?= e($cName) ?></span>
<?php if ($cMine): ?><span class="rv-mine">我</span><?php endif; ?>
<span class="rv-time"><?= e(substr((string)$c['created_at'], 0, 16)) ?></span>
<?php if ($cMine && $canWrite): ?>
<form method="post" action="<?= e(url('movies/comment_del')) ?>" class="rv-del"
onsubmit="return confirm('删除我的这条评论?它下面的回复也会一起删除。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="comment_id" value="<?= (int)$c['id'] ?>">
<button type="submit" class="rv-del-btn" title="删除我的评论">×</button>
</form>
<?php endif; ?>
</span>
<?= $feelTags($c['feelings']) ?>
<span class="rv-c-text"><?= nl2br(e($c['content'])) ?></span>
<span class="rv-c-acts"><?= $canWrite ? $commentBox((int)$rv['id'], (int)$c['id'], '回复') : '' ?></span>
</span>
</div>
<?php foreach ($thread['replies'] as $rep): ?>
<?php
$rMine = ((int)$rep['person_id'] === $meId);
$rName = (string)($rep['person_name'] ?? '已注销');
?>
<div class="rv-comment rv-comment-reply">
<?= $avatarHtml($rep['person_avatar'] ?? null, $rName, 'rv-avatar-sm') ?>
<span class="rv-c-body">
<span class="rv-c-head">
<span class="rv-name"><?= e($rName) ?></span>
<?php if ($rMine): ?><span class="rv-mine">我</span><?php endif; ?>
<span class="rv-time"><?= e(substr((string)$rep['created_at'], 0, 16)) ?></span>
<?php if ($rMine && $canWrite): ?>
<form method="post" action="<?= e(url('movies/comment_del')) ?>" class="rv-del"
onsubmit="return confirm('删除我的这条回复?')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="comment_id" value="<?= (int)$rep['id'] ?>">
<button type="submit" class="rv-del-btn" title="删除我的回复">×</button>
</form>
<?php endif; ?>
</span>
<?php if (!empty($rep['reply_to_name'])): ?>
<span class="rv-at">回复 <span>@<?= e($rep['reply_to_name']) ?></span></span>
<?php endif; ?>
<?= $feelTags($rep['feelings']) ?>
<span class="rv-c-text"><?= nl2br(e($rep['content'])) ?></span>
<span class="rv-c-acts"><?= $canWrite ? $commentBox((int)$rv['id'], (int)$rep['id'], '回复') : '' ?></span>
</span>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($canWrite): ?>
<div class="rv-actions">
<?= $commentBox((int)$rv['id'], 0, '评论') ?>
<?php if ($rvMine): ?>
<?= $rvForm('修改我的评价', $rvError !== '') ?>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php if ($hasPoster || $hasTicket || $m['photos']): ?>
<!-- 图片灯箱:点任意一张图在当前页弹出,可左右翻看 / Esc 关闭(MovieLog 同款) -->
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-hidden="true" aria-label="图片查看器">
<button type="button" class="lb-btn lb-close" id="lbClose" aria-label="关闭">✕</button>
<button type="button" class="lb-btn lb-nav lb-prev" id="lbPrev" aria-label="上一张">‹</button>
<button type="button" class="lb-btn lb-nav lb-next" id="lbNext" aria-label="下一张">›</button>
<div class="lb-stage" id="lbStage"><img class="lb-img" id="lbImg" alt=""></div>
<div class="lb-bar">
<span class="lb-caption" id="lbCaption"></span>
<span class="lb-counter" id="lbCounter"></span>
</div>
</div>
<script>
(function () {
var items = [];
<?php if ($hasPoster): ?>
items.push({url: <?= json_encode(media_url((string)$m['poster_key']), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, cap: <?= json_encode('海报 · ' . $m['title'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>});
<?php endif; ?>
<?php if ($hasTicket): ?>
items.push({url: <?= json_encode(media_url((string)$m['ticket_key']), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, cap: <?= json_encode('票根 · ' . $m['title'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>});
<?php endif; ?>
<?php foreach ($m['photos'] as $ph): ?>
items.push({url: <?= json_encode(media_url((string)$ph['key']), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, cap: <?= json_encode('照片 · ' . $m['title'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>});
<?php endforeach; ?>
var box = document.getElementById('lightbox');
var stageEl = document.getElementById('lbStage');
var imgEl = document.getElementById('lbImg');
var capEl = document.getElementById('lbCaption');
var cntEl = document.getElementById('lbCounter');
var closeEl = document.getElementById('lbClose');
var prevEl = document.getElementById('lbPrev');
var nextEl = document.getElementById('lbNext');
if (!box || !items.length) return;
var idx = 0, opener = null;
function render() {
imgEl.src = items[idx].url;
capEl.textContent = items[idx].cap;
cntEl.textContent = (idx + 1) + ' / ' + items.length;
var multi = items.length > 1;
prevEl.hidden = !multi;
nextEl.hidden = !multi;
}
function open(i, trigger) {
idx = Math.max(0, Math.min(items.length - 1, i));
opener = trigger || null;
box.classList.add('open');
box.setAttribute('aria-hidden', 'false');
render();
closeEl.focus();
}
function close() {
box.classList.remove('open');
box.setAttribute('aria-hidden', 'true');
if (opener && opener.focus) opener.focus();
opener = null;
}
function step(d) { idx = (idx + d + items.length) % items.length; render(); }
Array.prototype.forEach.call(document.querySelectorAll('[data-lb-index]'), function (el) {
var i = parseInt(el.getAttribute('data-lb-index'), 10);
if (isNaN(i)) return;
el.addEventListener('click', function (e) { e.preventDefault(); open(i, el); });
el.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(i, el); }
});
});
closeEl.addEventListener('click', close);
prevEl.addEventListener('click', function (e) { e.stopPropagation(); step(-1); });
nextEl.addEventListener('click', function (e) { e.stopPropagation(); step(1); });
box.addEventListener('click', function (e) { if (e.target === box || e.target === stageEl) close(); });
imgEl.addEventListener('click', function (e) { e.stopPropagation(); });
document.addEventListener('keydown', function (e) {
if (!box.classList.contains('open')) return;
if (e.key === 'Escape') { close(); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
else if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
});
var tx = 0, ty = 0;
stageEl.addEventListener('touchstart', function (e) {
tx = e.changedTouches[0].clientX;
ty = e.changedTouches[0].clientY;
}, { passive: true });
stageEl.addEventListener('touchend', function (e) {
var dx = e.changedTouches[0].clientX - tx;
var dy = e.changedTouches[0].clientY - ty;
if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) step(dx < 0 ? 1 : -1);
}, { passive: true });
})();
</script>
<?php endif; ?>
<?php if ($canWrite): ?>
<!-- 打分实时提示「几分 · 怎么样」;纯增强,radio 没 JS 也能选 -->
<script>
(function () {
var form = document.getElementById('reviewForm');
var word = document.getElementById('rvWord');
if (!form || !word) return;
var WORDS = <?= json_encode(\App\MovieReview::ratingWords(), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
function sync() {
var picked = form.querySelector('input[name="rating"]:checked');
if (!picked) { word.textContent = '未评分'; word.classList.remove('is-set'); return; }
var v = parseInt(picked.value, 10);
word.textContent = v + ' 分 · ' + (WORDS[v - 1] || '');
word.classList.add('is-set');
}
Array.prototype.forEach.call(form.querySelectorAll('input[name="rating"]'), function (el) {
el.addEventListener('change', sync);
});
sync();
})();
</script>
<?php endif; ?> <?php endif; ?>