Files
laoyang 746f02581e 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 覆盖宿主模板的观影记录(编辑页简介/备注回填被清空)
2026-10-01 00:12:04 +08:00

399 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 观影评价与讨论(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;
}
}