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:
+110
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
); ?>
|
); ?>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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; ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user