From a2c26ad0020de7e89e6ee4ae1a61ffb1f8277e2c Mon Sep 17 00:00:00 2001 From: laoyang Date: Tue, 22 Sep 2026 11:42:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=AF=84=E4=BB=B7=E4=B8=8E?= =?UTF-8?q?=E8=AE=A8=E8=AE=BA=EF=BC=9A=E6=89=93=E5=88=86=201-10=20+=20?= =?UTF-8?q?=E8=A7=82=E5=90=8E=E6=84=9F=E5=8F=97=20emoji?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 规则:只要有登录账号就能评价,与「是不是观影人」无关;评价归属 admins - 数据层 lib/reviews.php: · reviews(movie_id + admin_id 唯一索引,一个账号一部电影只一条) · review_comments(feelings 存感受 key 的 JSON 数组) · 感受目录 feelingCatalog():12 种,每种一个专属 emoji,文案与 emoji 不入库 - detail.php 新增「💬 评价与讨论」:1-10 打分(原生 radio,无 JS 也能用)、 评价正文、评价列表、每条评价下的讨论与多选感受、均分概况 - index.php:有评价的电影卡片显示 ⭐ 均分 - delete.php:删电影时显式清理评价与讨论(SQLite 外键级联默认未开启) - 顺带修一个隐患:delete.php 取一行后没关游标,读锁一直挂着, 后续同一连接上的 DELETE 拿不到写锁,页面会卡满 60 秒 --- README.md | 31 ++- config.php | 1 + delete.php | 8 + detail.php | 671 ++++++++++++++++++++++++++++++++++++++++++++++++ index.php | 13 + lib/db.php | 38 +++ lib/reviews.php | 321 +++++++++++++++++++++++ 7 files changed, 1081 insertions(+), 2 deletions(-) create mode 100644 lib/reviews.php diff --git a/README.md b/README.md index c26cccd..c498401 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,16 @@ - **安全约束**:不允许删除最后一个管理员,也不允许删除自己。 - **个人资料**:修改自己的用户名与密码(需验证旧密码)。 +### 评价与讨论 +- **谁可以评价**:**只要有登录账号就能评价**,与「是不是观影人」无关——没一起去看过的人也能打分、写评价、参与讨论。评价的归属是账号(`admins`),不是观影人(`persons`)。 +- **打分 1-10**:详情页「💬 评价与讨论」区块里点数字即可打分,右侧实时显示「7 分 · 不错」;打分用的是原生单选框,禁用 JS 也能照常提交。 +- **写评价**:一段正文(最多 5000 字)。**每个账号对每部电影只有一条评价**,再次提交即修改,页面上会标注「已编辑」。 +- **讨论**:任意账号都能在任意一条评价下面参与讨论(正文最多 1000 字)。 +- **观后感受(可多选)**:发讨论时可以勾选自己的观后感受,每种感受一个专属 emoji:😄 开心、🥹 感动、🥰 温暖、🤩 激动、🤔 深思、😰 紧张、😨 恐惧、😲 震惊、😢 难受、😠 气愤、😴 无聊、😵 困惑。勾选多少个都行,展示时按固定顺序排列。 + - 感受只把 key(如 `happy`)存进数据库,emoji 与文案统一在 `lib/reviews.php` 的 `feelingCatalog()` 里维护,**以后换 emoji / 改文案都不用动数据**。 +- **评分概况**:详情页标题栏显示「N 条评价 · 均分 X」;首页列表卡片上有评价的电影会显示 `⭐ 均分`。 +- **删除**:只能删除**自己**的评价与讨论;删除评价时其下的讨论一并删除。删除电影时也会清掉对应的评价与讨论(SQLite 默认未开启外键级联,由程序手动清理)。 + ### 基础数据维护 - **影院管理**:增删改查,支持「所在地方 / 影院名称 / 详细地址」三段式信息,内置 50 种预设配色,每个影院可用专属颜色区分;删除已被引用的影院时会给出醒目提示并要求二次确认(关联电影的影院字段将被置空)。 - **观影人管理**:增删改查,支持头像裁剪上传与专属配色;已被电影引用的观影人不允许直接删除。 @@ -96,6 +106,7 @@ MovieLog_Server/ │ │ └── meta.php # 实例元数据鉴权(ECS RAM 角色 / CVM CAM 角色)与本机地域查询 │ ├── auth.php # 登录校验、管理员账号增删改 │ ├── movies.php # 电影/影院读取、搜索,以及表单关联数据的解析 +│ ├── reviews.php # 评价与讨论:打分 1-10、正文、讨论、观后感受目录(emoji) │ └── stats.php # 统计仪表板与年度报告查询 ├── index.php # 首页:观影列表(瀑布流)+ 快捷搜索 ├── add.php # 添加电影 @@ -130,7 +141,7 @@ MovieLog_Server/ └── avatars/ # 观影人头像 ``` -> 页面只需 `require_once 'config.php'`,`lib/` 下的模块会由引导文件统一加载,并保证依赖顺序(db → helpers → auth → movies → stats)。 +> 页面只需 `require_once 'config.php'`,`lib/` 下的模块会由引导文件统一加载,并保证依赖顺序(db → settings → storage → helpers → auth → movies → reviews → stats)。 --- @@ -146,6 +157,8 @@ MovieLog_Server/ | `persons` | 观影人 | `name`(唯一)、`color`、`avatar` | | `viewers` | 电影 ↔ 观影人关联 | `movie_id`、`person_id`、`name`(历史遗留字段) | | `movie_photos` | 电影多图 | `movie_id`、`photo_path`、`created_at` | +| `reviews` | 电影评价 | `movie_id`、`admin_id`(评价人是账号,不是观影人)、`rating`(1-10)、`content`、`created_at`、`updated_at`;**`(movie_id, admin_id)` 上有唯一索引**,保证一个账号一部电影只一条 | +| `review_comments` | 评价下的讨论 | `review_id`、`admin_id`、`content`、`feelings`(感受 key 的 JSON 数组,如 `["happy","sad"]`)、`created_at` | | `settings` | 键值配置与缓存 | `name`(唯一)、`value`。存存储驱动选择、各云厂商配置、图片访问方式等;**云厂商密钥以 `enc:v1:gcm:` 前缀密文落库**(密钥文件 `db/.settings.key`,首次保存时自动生成,**丢失后密文不可恢复**) | `settings` 中除配置外的运行时缓存键(都可安全清空,程序会自动重建): @@ -156,7 +169,9 @@ MovieLog_Server/ | `cache_ecs_region` | 本机 ECS 所在地域(用于判断内网 endpoint 是否可用) | 1 小时 | | `cache_oss_internal` | 内网 endpoint 可用性判定结论 | 可用 1 小时 / 不可用 30 分钟 | -外键约束:`movies.cinema_id → cinemas.id`(`ON DELETE SET NULL`)、`viewers.movie_id → movies.id`(`ON DELETE CASCADE`)、`movie_photos.movie_id → movies.id`(`ON DELETE CASCADE`)。 +外键约束:`movies.cinema_id → cinemas.id`(`ON DELETE SET NULL`)、`viewers.movie_id → movies.id`(`ON DELETE CASCADE`)、`movie_photos.movie_id → movies.id`(`ON DELETE CASCADE`)、`reviews.movie_id → movies.id`(`ON DELETE CASCADE`)、`review_comments.review_id → reviews.id`(`ON DELETE CASCADE`)。 + +> SQLite 默认是**关闭**外键约束的(`PRAGMA foreign_keys` 未开启),上面这些 `ON DELETE CASCADE` 实际不会生效。因此删除电影、删除评价时,程序都会**显式地把子表数据一并清掉**(见 `delete.php` 与 `lib/reviews.php`),不要依赖级联。 **历史数据迁移**:`initDB()` 会自动把早期版本存在 `viewers.name` 里的姓名去重后写入 `persons`,并回填 `viewers.person_id`,老库可直接升级。 @@ -249,6 +264,18 @@ server { 2. 进入「年度报告」,页面顶部切换年份即可查看。 3. 未绑定观影人时报告为空并给出提示。 +### 打分、写评价与参与讨论 + +1. 打开任意一部电影的详情页,滚到最下面的「💬 评价与讨论」。 +2. **打分**:点「我的评分」后面的 1-10 任意一个数字(选中会变橙色),旁边实时显示「几分 · 怎么样」。 +3. **写评价**:在下面文本框里写正文,点「📝 发表评价」。 + - 已经评价过时,这一块会变成「✏️ 修改我的评价」,按钮是「💾 保存修改」——同一部电影不会留下两条你的评价。 +4. **参与讨论**:在别人的(或自己的)任意一条评价下面,写一句话,顺手勾几个**观后感受**(😄 🥹 🥰 🤩 🤔 😰 😨 😲 😢 😠 😴 😵,可多选),点「💬 发表讨论」。 + - 讨论会带上评价人的头像、昵称,以及你选的感受 emoji。 +5. **删除**:自己发的评价和讨论,右侧有删除按钮(别人的没有);删评价时下面的讨论会一起删掉。 + +> 评价人是**登录账号**。家里几个人各有一个账号时,同一部电影可以各自打分,标题栏会显示「N 条评价 · 均分 X」。 + ### CSV 批量导入 CSV **共 9 列,顺序固定**: diff --git a/config.php b/config.php index b451b61..9de2057 100644 --- a/config.php +++ b/config.php @@ -45,6 +45,7 @@ require_once ROOT_DIR . '/lib/storage.php'; // 对象存储门面(含各云 require_once ROOT_DIR . '/lib/helpers.php'; // 通用工具函数 require_once ROOT_DIR . '/lib/auth.php'; // 登录校验与账号管理 require_once ROOT_DIR . '/lib/movies.php'; // 电影 / 影院 / 搜索 +require_once ROOT_DIR . '/lib/reviews.php'; // 评价与评价下的讨论 require_once ROOT_DIR . '/lib/stats.php'; // 统计与年度报告 // 初始化数据库 diff --git a/delete.php b/delete.php index 9c3a1b3..34bbeb6 100644 --- a/delete.php +++ b/delete.php @@ -14,6 +14,9 @@ $db = getDB(); $stmt = $db->prepare("SELECT poster_path, ticket_path, photo_path FROM movies WHERE id = ?"); $stmt->execute([$id]); $movie = $stmt->fetch(); +// 取一行就够,但游标不关的话这条连接会一直挂着共享读锁, +// 后面删评价 / 删电影要升级成写锁时就会被自己挡住(PDO 默认等 60 秒,表现成页面卡死)。 +$stmt->closeCursor(); if ($movie) { // 删除海报、票根、旧单张照片 @@ -29,6 +32,11 @@ if ($movie) { deleteStoredFile($p['photo_path']); } + // 删除这部电影的评价与评价下的讨论 + // (SQLite 默认没开 PRAGMA foreign_keys,reviews 上的 ON DELETE CASCADE 不会生效,必须手动清) + // 传 $db 进去复用本连接:读写都在一条连接上,不会互相等锁。 + deleteReviewsOfMovie($id, $db); + // 删除电影(viewers 和 movie_photos 会自动级联删除) $stmt = $db->prepare("DELETE FROM movies WHERE id = ?"); $stmt->execute([$id]); diff --git a/detail.php b/detail.php index 0dc3768..bbb77d1 100644 --- a/detail.php +++ b/detail.php @@ -19,6 +19,121 @@ if (!$movie) { } $admin = getCurrentAdmin(); +$adminId = (int)($admin['id'] ?? 0); + +// ---------- 评价与讨论 ---------- +// 规则:只要有登录账号就能对任意一部电影打分(1-10)+ 写评价,并在别人的评价 +// 下面参与讨论;评价人是「账号」(admins),与「观影人」(persons / viewers)无关。 +// 提交后一律重定向回本页(POST → 重定向 → GET),刷新不会重复提交, +// 一次性提示走 session。 +$rvFlash = null; +if (isset($_SESSION['rv_flash'])) { + $rvFlash = $_SESSION['rv_flash']; + unset($_SESSION['rv_flash']); +} + +$rvError = ''; // 评价表单的错误 +$rvFormRating = 0; +$rvFormContent = ''; +$rvCommentErrId = 0; // 哪一条评价的讨论表单报错 +$rvCommentErr = ''; + +// 当前账号已有的评价(有则表单变成「修改」) +$myReview = getMyReview($id, $adminId); +if ($myReview) { + $rvFormRating = (int)$myReview['rating']; + $rvFormContent = $myReview['content']; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $adminId > 0) { + $rvAction = $_POST['action'] ?? ''; + $postedMovie = (int)($_POST['movie_id'] ?? 0); + + // movie_id 必须对得上:表单里的 id 是客户端可控的,防止写到别的电影上 + if ($postedMovie === $id) { + if ($rvAction === 'save_review') { + $rating = (int)($_POST['rating'] ?? 0); + $content = trim((string)($_POST['content'] ?? '')); + $rvFormRating = $rating; + $rvFormContent = $content; + + if ($rating < 1 || $rating > 10) { + $rvError = '请先选一个 1-10 的评分'; + } elseif ($content === '') { + $rvError = '评价内容不能为空'; + } elseif (reviewTextLength($content) > REVIEW_CONTENT_MAX) { + $rvError = '评价内容太长了(最多 ' . REVIEW_CONTENT_MAX . ' 字)'; + } elseif (saveReview($id, $adminId, $rating, $content)) { + $_SESSION['rv_flash'] = ['type' => 'success', 'msg' => $myReview ? '评价已更新' : '评价已发布']; + header('Location: detail.php?id=' . $id . '#reviews'); + exit; + } else { + $rvError = '保存失败,请稍后再试'; + } + + } elseif ($rvAction === 'delete_review') { + $reviewId = (int)($_POST['review_id'] ?? 0); + if (deleteReview($reviewId, $adminId)) { + $_SESSION['rv_flash'] = ['type' => 'success', 'msg' => '评价已删除']; + } else { + $_SESSION['rv_flash'] = ['type' => 'error', 'msg' => '只能删除自己的评价']; + } + header('Location: detail.php?id=' . $id . '#reviews'); + exit; + + } elseif ($rvAction === 'add_comment') { + $reviewId = (int)($_POST['review_id'] ?? 0); + $content = trim((string)($_POST['content'] ?? '')); + $feelings = normalizeFeelings($_POST['feelings'] ?? []); + $review = getReviewById($reviewId); + + if ($content === '') { + $rvCommentErrId = $reviewId; + $rvCommentErr = '讨论内容不能为空'; + } elseif (reviewTextLength($content) > REVIEW_COMMENT_MAX) { + $rvCommentErrId = $reviewId; + $rvCommentErr = '讨论内容太长了(最多 ' . REVIEW_COMMENT_MAX . ' 字)'; + } elseif (!$review || (int)$review['movie_id'] !== $id) { + $rvCommentErrId = $reviewId; + $rvCommentErr = '这条评价不存在'; + } else { + $newId = addReviewComment($reviewId, $adminId, $content, $feelings); + if ($newId) { + $_SESSION['rv_flash'] = ['type' => 'success', 'msg' => '讨论已发表']; + header('Location: detail.php?id=' . $id . '#reviews'); + exit; + } + $rvCommentErrId = $reviewId; + $rvCommentErr = '保存失败,请稍后再试'; + } + + } elseif ($rvAction === 'delete_comment') { + $commentId = (int)($_POST['comment_id'] ?? 0); + if (deleteReviewComment($commentId, $adminId)) { + $_SESSION['rv_flash'] = ['type' => 'success', 'msg' => '讨论已删除']; + } else { + $_SESSION['rv_flash'] = ['type' => 'error', 'msg' => '只能删除自己的讨论']; + } + header('Location: detail.php?id=' . $id . '#reviews'); + exit; + } + } +} + +$reviews = getMovieReviews($id); +$rvStats = reviewStats($id); +$feelings = feelingCatalog(); +// 讨论的错误提示默认贴在那条评价自己的表单上;但如果出错的评价根本不在这部电影 +// 下(比如被改过的 review_id),页面上找不到对应的表单,就退化成整段顶部的提示, +// 否则错误会被静默吞掉。 +$rvCommentErrShown = false; +if ($rvCommentErrId > 0) { + foreach ($reviews as $rv) { + if ((int)$rv['id'] === $rvCommentErrId) { $rvCommentErrShown = true; break; } + } +} +// 1-10 分对应的文案,交给前端实时显示(下标 = 分值 - 1) +$rvRatingWords = array_map('ratingWord', range(1, 10)); // ---------- 灯箱图片序列:海报 → 票根 → 观影照片 ---------- // 以前每张图都是用 JS 的 window.open 弹到新窗口,没有关闭按钮、体验很差。 @@ -286,6 +401,369 @@ $hasGallery = !empty($gallery); margin-top: 8px; } + /* ---------- 评价与讨论 ---------- */ + .rv-flash { + padding: 10px 14px; + border-radius: 10px; + font-size: 14px; + margin-bottom: 16px; + } + .rv-flash-success { + background: rgba(46, 204, 113, 0.10); + border: 1px solid rgba(46, 204, 113, 0.28); + color: #1e8449; + } + .rv-flash-error { + background: rgba(231, 76, 60, 0.08); + border: 1px solid rgba(231, 76, 60, 0.25); + color: #c0392b; + } + .rv-error { + background: rgba(231, 76, 60, 0.08); + border: 1px solid rgba(231, 76, 60, 0.25); + color: #c0392b; + padding: 8px 12px; + border-radius: 8px; + font-size: 13px; + margin-bottom: 10px; + } + .rv-form { + background: var(--bg-secondary); + border-radius: 12px; + padding: 16px 18px; + margin-bottom: 22px; + } + .rv-form-head { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 12px; + } + .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(--text-primary); + } + /* 1-10 打分:用原生 radio,没 JS 也能选;外观全在 + 后面的 span 上 */ + .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(--border-color); + border-radius: 8px; + /* --input-bg 在部分页面未定义,给个兜底 */ + background: var(--input-bg, var(--card-bg)); + color: var(--text-secondary); + font-size: 14px; + font-weight: 600; + cursor: pointer; + user-select: none; + transition: var(--transition); + } + .rv-num:hover span { + border-color: var(--primary); + color: var(--primary); + } + .rv-num input:checked + span { + background: linear-gradient(135deg, var(--primary), var(--primary-light)); + border-color: transparent; + color: #fff; + box-shadow: 0 3px 10px rgba(230, 126, 34, 0.30); + } + .rv-num:focus-within span { + outline: 2px solid var(--primary); + outline-offset: 1px; + } + .rv-word { + font-size: 13px; + color: var(--text-secondary); + } + .rv-word.is-set { + color: var(--primary); + font-weight: 600; + } + .rv-textarea { + width: 100%; + min-height: 92px; + resize: vertical; + padding: 12px 14px; + border: 1px solid var(--border-color); + border-radius: 10px; + font-size: 15px; + font-family: inherit; + line-height: 1.7; + background: var(--input-bg, var(--card-bg)); + color: var(--text-primary); + box-sizing: border-box; + } + .rv-textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 4px rgba(230, 126, 34, 0.12); + } + .rv-textarea-sm { + min-height: 62px; + 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(--text-secondary); + } + .rv-empty { + color: var(--text-secondary); + font-size: 14px; + } + .rv-list { + display: flex; + flex-direction: column; + gap: 18px; + } + .rv-item { + border-top: 1px solid var(--border-color); + 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-who { + flex: 1; + min-width: 0; + } + .rv-name { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + .rv-mine { + display: inline-block; + vertical-align: middle; + margin-left: 6px; + font-size: 11px; + line-height: 16px; + background: var(--primary); + color: #fff; + border-radius: 6px; + padding: 0 6px; + } + .rv-time { + display: block; + font-size: 12px; + color: var(--text-secondary); + } + .rv-score { + flex: 0 0 auto; + font-size: 20px; + font-weight: 700; + color: var(--primary); + font-variant-numeric: tabular-nums; + } + .rv-score-max { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + } + .rv-del { + margin-left: auto; + } + .rv-del-btn { + background: none; + border: none; + cursor: pointer; + color: var(--text-secondary); + font-size: 14px; + line-height: 1; + padding: 4px 6px; + border-radius: 6px; + transition: background-color 0.2s ease; + } + .rv-del-btn:hover { + background: rgba(231, 76, 60, 0.12); + color: #e74c3c; + } + /* 正文用 pre-wrap 保留换行,就不必再 nl2br 了(也避免转义后再插标签) */ + .rv-content { + margin: 8px 0 2px 44px; + font-size: 15px; + line-height: 1.75; + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-word; + } + .rv-updated { + margin-left: 44px; + font-size: 12px; + color: var(--text-secondary); + } + .rv-comments { + margin: 10px 0 10px 44px; + display: flex; + flex-direction: column; + gap: 8px; + } + .rv-comment { + display: flex; + gap: 8px; + background: var(--bg-secondary); + border-radius: 10px; + padding: 8px 12px; + } + .rv-c-body { + flex: 1; + min-width: 0; + } + .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(--text-primary); + white-space: pre-wrap; + word-break: break-word; + } + .rv-feelings { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 4px 0 2px; + } + .rv-comment-form { + margin-left: 44px; + } + .rv-feeling-pick { + margin-top: 8px; + } + .rv-pick-label { + display: block; + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 6px; + } + .feel-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .feel-chip { + display: inline-flex; + } + /* 可勾选的感受:原生 checkbox,外观在 .feel-inner 上 */ + .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(--border-color); + border-radius: 20px; + background: var(--input-bg, var(--card-bg)); + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; + user-select: none; + transition: var(--transition); + } + .feel-pick:hover .feel-inner { + border-color: var(--primary); + } + .feel-pick input:checked + .feel-inner { + border-color: var(--primary); + background: rgba(230, 126, 34, 0.12); + color: var(--primary); + font-weight: 600; + } + .feel-pick:focus-within .feel-inner { + outline: 2px solid var(--primary); + 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(230, 126, 34, 0.10); + color: var(--primary); + font-size: 12px; + } + @media (max-width: 600px) { + .rv-content, + .rv-updated, + .rv-comments, + .rv-comment-form { + margin-left: 0; + } + } + /* ---------- 图片灯箱(替代原来的 window.open 弹窗) ---------- z-index 要压过导航栏(导航栏是 100),这里用 300。 */ .lb-openable { @@ -551,6 +1029,168 @@ $hasGallery = !empty($gallery); + + +
+

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

+ + +
+ +
+ + + +
+ + +
+
+ 我的评分 +
+ + + +
+ + 0 ? $rvFormRating . ' 分 · ' . h(ratingWord($rvFormRating)) : '未评分' ?> + +
+ +
+ + +
+ 每个账号对每部电影只有一条评价,再次提交即为修改。 + +
+
+ + +

还没有人评价这部电影,你可以是第一个 👆

+ +
+ + +
+
+ +
+ + 我 + +
+ /10 + +
+ + + + +
+ +
+
+ +
已编辑
+ + + +
+ + +
+ +
+
+ + 我 + + +
+ + + + +
+ +
+ +
+ + + + + + +
+ +
+
+
+ +
+ + + +
+ + + + +
+ + +
+ 看完的感受(可多选) +
+ $f): ?> + + +
+
+
+ +
+
+
+ +
+ + + +
+ +
@@ -679,6 +1319,37 @@ $hasGallery = !empty($gallery); + + + \ No newline at end of file diff --git a/index.php b/index.php index ea2a8e0..e700216 100644 --- a/index.php +++ b/index.php @@ -16,6 +16,9 @@ if (!empty($keyword)) { } else { $movies = getMovies(); } + +// 挂上评价概况(几条评价 / 平均分),一次查询,列表里只展示有评价的电影 +attachReviewStatsToMovies($movies); ?> @@ -97,6 +100,11 @@ if (!empty($keyword)) { flex-wrap: wrap; gap: 4px 8px; } + /* 评价均分(有人评价过才显示) */ + .meta-rating { + color: var(--primary); + font-weight: 600; + } @media (max-width: 600px) { .search-bar { flex-wrap: wrap; @@ -176,6 +184,11 @@ if (!empty($keyword)) { 🏛️ + + + ⭐ /10 + + diff --git a/lib/db.php b/lib/db.php index 1e9f2c5..7be244a 100644 --- a/lib/db.php +++ b/lib/db.php @@ -100,6 +100,44 @@ function initDB() { ) "); + // 电影评价表 + // 评价人是「登录账号」(admins)——不是观影人(persons / viewers): + // 没一起去看的人只要有账号,也能打分、写评价并参与讨论。 + $db->exec(" + CREATE TABLE IF NOT EXISTS reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + movie_id INTEGER NOT NULL, + admin_id INTEGER NOT NULL, + rating INTEGER NOT NULL, + content TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (movie_id) REFERENCES movies(id) ON DELETE CASCADE, + FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE + ) + "); + + // 评价下的讨论(评论)。 + // feelings 存的是感受 key 的 JSON 数组(如 [\"happy\",\"sad\"]), + // emoji 与文案不入库——统一在 lib/reviews.php 的目录里维护,改文案不用动数据。 + $db->exec(" + CREATE TABLE IF NOT EXISTS review_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + review_id INTEGER NOT NULL, + admin_id INTEGER NOT NULL, + content TEXT NOT NULL, + feelings TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE, + FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE + ) + "); + + // 一部电影每个账号只保留一条评价:再次提交就是修改(见 saveReview) + $db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_movie_admin ON reviews(movie_id, admin_id)"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_reviews_movie ON reviews(movie_id)"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_review_comments_review ON review_comments(review_id)"); + // ===== 迁移字段 ===== try { $db->exec("ALTER TABLE cinemas ADD COLUMN color TEXT DEFAULT '#888888'"); } catch (PDOException $e) {} try { $db->exec("ALTER TABLE admins ADD COLUMN avatar TEXT"); } catch (PDOException $e) {} diff --git a/lib/reviews.php b/lib/reviews.php new file mode 100644 index 0000000..d056f02 --- /dev/null +++ b/lib/reviews.php @@ -0,0 +1,321 @@ + [emoji, 中文名]。评论时可多选,每种感受一个专属 emoji。 +function feelingCatalog() { + static $catalog = null; + if ($catalog === null) { + $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' => '困惑'], + ]; + } + return $catalog; +} + +// 取某一种感受的 emoji + 文案;key 不存在时返回 null +function feelingInfo($key) { + $catalog = feelingCatalog(); + return isset($catalog[$key]) ? $catalog[$key] : null; +} + +// ===== 文本长度(按 UTF-8 字符计;没有 mbstring 时退化为字节数) ===== +function reviewTextLength($str) { + return function_exists('mb_strlen') ? mb_strlen((string)$str, 'UTF-8') : strlen((string)$str); +} + +// ===== 感受的校验 / 存取 ===== + +// 把表单提交(数组)或库里取出的数组,整理成合法的感受 key 列表: +// 过滤不在目录里的值、去重、截断到上限,并保持目录里的顺序。 +function normalizeFeelings($raw, $max = REVIEW_FEELING_MAX) { + $catalog = feelingCatalog(); + $picked = []; + if (is_array($raw)) { + foreach ($raw as $key) { + $key = is_string($key) ? trim($key) : ''; + if ($key !== '' && isset($catalog[$key]) && !in_array($key, $picked, true)) { + $picked[] = $key; + } + } + } + // 保持目录顺序,读出来展示时才不会跟着勾选顺序乱跳 + $ordered = []; + foreach ($catalog as $key => $info) { + if (in_array($key, $picked, true)) $ordered[] = $key; + } + return array_slice($ordered, 0, max(0, (int)$max)); +} + +// 解析库里 feelings 字段(JSON 数组字符串)→ 合法的 key 列表 +function parseStoredFeelings($json) { + if ($json === null || $json === '') return []; + $keys = json_decode((string)$json, true); + if (!is_array($keys)) return []; + return normalizeFeelings($keys); +} + +// 感受列表 → 入库用的 JSON 字符串 +function feelingsToJson(array $keys) { + return json_encode(array_values($keys), JSON_UNESCAPED_UNICODE); +} + +// ===== 评分文案 ===== +// 1-10 分给一句词,详情页打分时实时显示,评价列表里也用它做 title。 +function ratingWord($rating) { + $r = (int)$rating; + if ($r <= 2) return '很失望'; + if ($r <= 4) return '不太行'; + if ($r <= 6) return '还行'; + if ($r <= 8) return '不错'; + return '很棒'; +} + +// ===== 读取 ===== + +// 某部电影的全部评价(新在前),并把讨论一次性挂到每条评价的 comments 上 +function getMovieReviews($movie_id) { + $db = getDB(); + $stmt = $db->prepare(" + SELECT r.*, a.username, a.avatar + FROM reviews r + LEFT JOIN admins a ON r.admin_id = a.id + WHERE r.movie_id = ? + ORDER BY r.created_at DESC, r.id DESC + "); + $stmt->execute([$movie_id]); + $reviews = $stmt->fetchAll(); + attachCommentsToReviews($reviews); + return $reviews; +} + +// 给一批评价挂上 comments(一次查询,避免每条评价查一次库) +function attachCommentsToReviews(array &$reviews) { + if (empty($reviews)) return; + $ids = array_column($reviews, 'id'); + $ids = array_values(array_filter($ids, function ($v) { return (int)$v > 0; })); + if (empty($ids)) return; + + $db = getDB(); + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $stmt = $db->prepare(" + SELECT c.*, a.username, a.avatar + FROM review_comments c + LEFT JOIN admins a ON c.admin_id = a.id + WHERE c.review_id IN ($placeholders) + ORDER BY c.created_at ASC, c.id ASC + "); + $stmt->execute($ids); + + $map = []; + foreach ($stmt->fetchAll() as $row) { + $row['feelings'] = parseStoredFeelings($row['feelings'] ?? ''); + $map[(int)$row['review_id']][] = $row; + } + foreach ($reviews as &$review) { + $review['comments'] = $map[(int)$review['id']] ?? []; + } + unset($review); +} + +// 当前账号在这部电影上的评价(没有则 null) +function getMyReview($movie_id, $admin_id) { + if ((int)$admin_id <= 0) return null; + $db = getDB(); + $stmt = $db->prepare("SELECT * FROM reviews WHERE movie_id = ? AND admin_id = ?"); + $stmt->execute([$movie_id, $admin_id]); + $row = $stmt->fetch(); + return $row ?: null; +} + +// 取单条评价(用于校验评论挂在的评价确实属于这部电影) +function getReviewById($review_id) { + $db = getDB(); + $stmt = $db->prepare("SELECT * FROM reviews WHERE id = ?"); + $stmt->execute([$review_id]); + $row = $stmt->fetch(); + return $row ?: null; +} + +// 某部电影的评分概况:['count' => n, 'avg' => 7.5] +// 没人评价时 avg 为 null,展示时不要显示 0 分。 +function reviewStats($movie_id) { + $db = getDB(); + $stmt = $db->prepare("SELECT COUNT(*) AS c, AVG(rating) AS a FROM reviews WHERE movie_id = ?"); + $stmt->execute([$movie_id]); + $row = $stmt->fetch(); + $count = (int)($row['c'] ?? 0); + return [ + 'count' => $count, + 'avg' => $count > 0 ? round((float)$row['a'], 1) : null, + ]; +} + +// 给电影列表批量挂上评分概况(列表页展示均分用,一次查询搞定) +function attachReviewStatsToMovies(array &$movies) { + if (empty($movies)) return; + $ids = array_values(array_unique(array_filter(array_column($movies, 'id'), function ($v) { return (int)$v > 0; }))); + if (empty($ids)) return; + + $db = getDB(); + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $stmt = $db->prepare(" + SELECT movie_id, COUNT(*) AS c, AVG(rating) AS a + FROM reviews WHERE movie_id IN ($placeholders) GROUP BY movie_id + "); + $stmt->execute($ids); + + $map = []; + foreach ($stmt->fetchAll() as $row) { + $map[(int)$row['movie_id']] = [ + 'count' => (int)$row['c'], + 'avg' => round((float)$row['a'], 1), + ]; + } + foreach ($movies as &$movie) { + $movie['review_count'] = $map[(int)$movie['id']]['count'] ?? 0; + $movie['review_avg'] = $map[(int)$movie['id']]['avg'] ?? null; + } + unset($movie); +} + +// ===== 写入 ===== + +/** + * 发表 / 修改评价。一个账号对一部电影只保留一条:已有则更新,没有则插入。 + * 调用前请自行校验 rating 在 1-10、content 非空且未超长。 + * 成功返回 true。 + */ +function saveReview($movie_id, $admin_id, $rating, $content) { + $db = getDB(); + $rating = (int)$rating; + $content = trim((string)$content); + if ($rating < 1 || $rating > 10 || $content === '' || (int)$admin_id <= 0) { + return false; + } + + try { + $db->beginTransaction(); + $stmt = $db->prepare("SELECT id FROM reviews WHERE movie_id = ? AND admin_id = ?"); + $stmt->execute([$movie_id, $admin_id]); + $existing = $stmt->fetch(); + + if ($existing) { + $stmt = $db->prepare(" + UPDATE reviews SET rating = ?, content = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + "); + $stmt->execute([$rating, $content, $existing['id']]); + } else { + $stmt = $db->prepare(" + INSERT INTO reviews (movie_id, admin_id, rating, content) VALUES (?, ?, ?, ?) + "); + $stmt->execute([$movie_id, $admin_id, $rating, $content]); + } + $db->commit(); + return true; + } catch (PDOException $e) { + if ($db->inTransaction()) $db->rollBack(); + return false; + } +} + +// 删除自己的评价(连同下面的讨论一起删)。只能删自己的,删别人的返回 false。 +function deleteReview($review_id, $admin_id) { + $db = getDB(); + $stmt = $db->prepare("DELETE FROM reviews WHERE id = ? AND admin_id = ?"); + $stmt->execute([$review_id, $admin_id]); + if ($stmt->rowCount() <= 0) return false; + + // 外键级联在本项目里没有开启(SQLite 默认关闭 PRAGMA foreign_keys), + // 所以评论要手动清一遍,删掉评价后不能留下孤儿讨论。 + $stmt = $db->prepare("DELETE FROM review_comments WHERE review_id = ?"); + $stmt->execute([$review_id]); + return true; +} + +/** + * 在一条评价下发表讨论。$feelings 是感受 key 数组(可为空)。 + * 返回新评论 id,失败返回 false。 + */ +function addReviewComment($review_id, $admin_id, $content, array $feelings = []) { + $db = getDB(); + $content = trim((string)$content); + $review = getReviewById($review_id); + if (!$review || $content === '' || (int)$admin_id <= 0) { + return false; + } + + $stmt = $db->prepare(" + INSERT INTO review_comments (review_id, admin_id, content, feelings) + VALUES (?, ?, ?, ?) + "); + $ok = $stmt->execute([ + $review_id, + $admin_id, + $content, + feelingsToJson(normalizeFeelings($feelings)), + ]); + return $ok ? (int)$db->lastInsertId() : false; +} + +// 删除自己的讨论;只能删自己的 +function deleteReviewComment($comment_id, $admin_id) { + $db = getDB(); + $stmt = $db->prepare("DELETE FROM review_comments WHERE id = ? AND admin_id = ?"); + $stmt->execute([$comment_id, $admin_id]); + return $stmt->rowCount() > 0; +} + +/** + * 删除某部电影的全部评价与讨论(删电影时调用;级联未开启,需手动清)。 + * + * $db 可以传入调用方已经打开的连接:delete.php 手里那条连接上还有没读完的 + * 游标(持有共享读锁),此时另开一条连接去 DELETE 会撞上 PDO 默认的 + * 60 秒 busy timeout,表现成「页面卡死」。复用同一条连接就不会互等。 + */ +function deleteReviewsOfMovie($movie_id, $db = null) { + if (!($db instanceof PDO)) { + $db = getDB(); + } + $stmt = $db->prepare("SELECT id FROM reviews WHERE movie_id = ?"); + $stmt->execute([$movie_id]); + $ids = $stmt->fetchAll(PDO::FETCH_COLUMN); + if (empty($ids)) return; + + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $stmt = $db->prepare("DELETE FROM review_comments WHERE review_id IN ($placeholders)"); + $stmt->execute($ids); + $stmt = $db->prepare("DELETE FROM reviews WHERE movie_id = ?"); + $stmt->execute([$movie_id]); +}