评价区改成「按钮展开表单」,评论支持两级回复
页面默认不出现任何输入框,要写什么就点对应那个按钮: - detail.php:评价区的「写评价 / 修改我的评价 / 评论 / 回复」都做成按钮, 用原生 <details>/<summary> 折叠,点开才展开表单,提交后整页刷新自动收起。 零 JS、键盘可用。没评价过只在顶部有「写评价」;评价过则该按钮消失,改在自己 那条评价下面出现「修改我的评价」。一条评价都没有时显示「还没有评价,等你来评一个。」 - lib/reviews.php:评论改两级平铺。parent_id 为空 = 顶层评论,非空 = 对某条评论的 回复;写「回复的回复」时由 addReviewComment 规范化回顶层评论,所以库里最多两层、 页面永远只缩进一次,来回多少轮都不会越缩越窄。被回复者另存 reply_to_admin_id (存「人」不存评论 id,对方那条被删也不会悬空),页面用「回复 @某某」标出。 deleteReviewComment 删顶层评论时连带清掉它的回复。 - lib/db.php:review_comments 增 parent_id / reply_to_admin_id 两列 + 幂等迁移, 并给 parent_id 建索引。**修一个会让老库升级白屏的顺序 bug**:新列上的 CREATE INDEX 必须排在 ALTER TABLE 补列之后 —— 建表用的是 CREATE TABLE IF NOT EXISTS,对线上 已存在的旧表不生效,索引跑在补列之前会报 no such column: parent_id,整个 initDB 挂掉。全新安装看不出来,是 e2e 的「造旧库再跑一遍初始化」专测抓到的。 - README.md:命名由「评价与讨论」改为「评价与评论」,补上按钮交互、两级平铺、 回复 @某某 与新表字段的说明。 回归:reviews_e2e 84/84、fetch_movie 76/76、storage 196/196;A/B 47 个响应中 仅详情页相关 6 个有差异,且经折叠区块比对确认差异全部限定在评价区内。 线上 PHP 7.4.30 / 8.1.7 双版本迁移与写链路(顶层评论→回复→回复的回复→清理)全部通过。
This commit is contained in:
@@ -50,16 +50,21 @@
|
||||
- **安全约束**:不允许删除最后一个管理员,也不允许删除自己。
|
||||
- **个人资料**:修改自己的用户名与密码(需验证旧密码)。
|
||||
|
||||
### 评价与讨论
|
||||
### 评价与评论
|
||||
- **谁可以评价**:**只要有登录账号就能评价**,与「是不是观影人」无关——没一起去看过的人也能打分、写评价、参与讨论。评价的归属是账号(`admins`),不是观影人(`persons`)。
|
||||
- **打分 1-10**:详情页「💬 评价与讨论」区块里点数字即可打分,右侧实时显示「7 分 · 不错」;打分用的是原生单选框,禁用 JS 也能照常提交。
|
||||
- **页面上默认不出现任何输入框**:详情页「💬 评价与评论」区块上来只展示已有内容;要写什么就点对应那个按钮——「📝 写评价」「✏️ 修改我的评价」「💬 评论」「↩ 回复」,点开才当场展开表单,提交后页面刷新、表单自动收起。折叠用的是原生 `<details>/<summary>`,**不依赖 JS**,键盘也能操作。
|
||||
- **打分 1-10**:展开写评价表单后点数字即可打分,右侧实时显示「7 分 · 不错」;打分用的是原生单选框,禁用 JS 也能照常提交。
|
||||
- **写评价**:一段正文(最多 5000 字)。**每个账号对每部电影只有一条评价**,再次提交即修改,页面上会标注「已编辑」。
|
||||
- **讨论**:任意账号都能在任意一条评价下面参与讨论(正文最多 1000 字)。
|
||||
- **观后感受(可多选)**:**写评价时**和**发讨论时**都可以勾选自己的观后感受,每种感受一个专属 emoji:😄 开心、💖 感动、🥰 温暖、🤩 激动、🤔 深思、😰 紧张、😨 恐惧、😲 震惊、😢 难受、😠 气愤、😴 无聊、😵 困惑。勾选多少个都行(最多 6 个),展示时按固定顺序排列。
|
||||
- 没评价过时,区块顶部只有一个「📝 写评价」按钮;评价过之后这个按钮就消失了,改成在**自己那条评价下面**出现「✏️ 修改我的评价」。
|
||||
- 一条评价都没有时,区块里只显示一句「还没有评价,等你来评一个。」
|
||||
- **评论与回复**:任意账号都能在任意一条评价下评论,也能**回复任意一条评论**(正文最多 1000 字)。
|
||||
- 每条评价下面有「💬 评论」按钮,每条评论下面有「↩ 回复」按钮,同样**点开才出现输入框**。
|
||||
- 评论是**两级平铺**结构:回复统一平铺在它所属的顶层评论之下(不再逐层缩进),只把「回复 @某某」写出来。所以**回复的回复也只会落在同一层**,无论来回多少轮,视觉上永远只有两层,页面不会越缩越窄。
|
||||
- **观后感受(可多选)**:**写评价时**和**发评论 / 回复时**都可以勾选自己的观后感受,每种感受一个专属 emoji:😄 开心、💖 感动、🥰 温暖、🤩 激动、🤔 深思、😰 紧张、😨 恐惧、😲 震惊、😢 难受、😠 气愤、😴 无聊、😵 困惑。勾选多少个都行(最多 6 个),展示时按固定顺序排列。
|
||||
- 评价上的感受会显示在这条评价正文下面;修改评价时原来勾的会自动带上,取消勾选即清空。
|
||||
- 感受只把 key(如 `happy`)存进数据库(`reviews.feelings` / `review_comments.feelings`),emoji 与文案统一在 `lib/reviews.php` 的 `feelingCatalog()` 里维护,**以后换 emoji / 改文案都不用动数据**。
|
||||
- **评分概况**:详情页标题栏显示「N 条评价 · 均分 X」;首页列表卡片上有评价的电影会显示 `⭐ 均分`。
|
||||
- **删除**:只能删除**自己**的评价与讨论;删除评价时其下的讨论一并删除。删除电影时也会清掉对应的评价与讨论(SQLite 默认未开启外键级联,由程序手动清理)。
|
||||
- **删除**:只能删除**自己**的评价与评论;删除一条顶层评论时它下面的回复一并删除,删除评价时其下的评论全部删除。删除电影时也会清掉对应的评价与评论(SQLite 默认未开启外键级联,由程序手动清理)。
|
||||
|
||||
### 基础数据维护
|
||||
- **影院管理**:增删改查,支持「所在地方 / 影院名称 / 详细地址」三段式信息,内置 50 种预设配色,每个影院可用专属颜色区分;删除已被引用的影院时会给出醒目提示并要求二次确认(关联电影的影院字段将被置空)。
|
||||
@@ -107,7 +112,7 @@ MovieLog_Server/
|
||||
│ │ └── meta.php # 实例元数据鉴权(ECS RAM 角色 / CVM CAM 角色)与本机地域查询
|
||||
│ ├── auth.php # 登录校验、管理员账号增删改
|
||||
│ ├── movies.php # 电影/影院读取、搜索,以及表单关联数据的解析
|
||||
│ ├── reviews.php # 评价与讨论:打分 1-10、正文、讨论、观后感受目录(emoji)
|
||||
│ ├── reviews.php # 评价与评论:打分 1-10、正文、评论与两级回复、观后感受目录(emoji)
|
||||
│ └── stats.php # 统计仪表板与年度报告查询
|
||||
├── index.php # 首页:观影列表(瀑布流)+ 快捷搜索
|
||||
├── add.php # 添加电影
|
||||
@@ -159,7 +164,7 @@ MovieLog_Server/
|
||||
| `viewers` | 电影 ↔ 观影人关联 | `movie_id`、`person_id`、`name`(历史遗留字段) |
|
||||
| `movie_photos` | 电影多图 | `movie_id`、`photo_path`、`created_at` |
|
||||
| `reviews` | 电影评价 | `movie_id`、`admin_id`(评价人是账号,不是观影人)、`rating`(1-10)、`content`、`feelings`(写评价时选的观后感受,同样是 JSON 数组)、`created_at`、`updated_at`;**`(movie_id, admin_id)` 上有唯一索引**,保证一个账号一部电影只一条 |
|
||||
| `review_comments` | 评价下的讨论 | `review_id`、`admin_id`、`content`、`feelings`(感受 key 的 JSON 数组,如 `["happy","sad"]`)、`created_at` |
|
||||
| `review_comments` | 评价下的评论与回复(两级平铺) | `review_id`、`admin_id`、`content`、`feelings`(感受 key 的 JSON 数组,如 `["happy","sad"]`)、`parent_id`(**空 = 顶层评论;非空 = 对某条顶层评论的回复**——写回复的回复时会被规范化回顶层评论,库里永远不会更深)、`reply_to_admin_id`(被回复者的 `admins.id`,用于显示「回复 @某某」;存「人」而非评论 id,对方那条被删也不会悬空)、`created_at`;`parent_id` 上有索引(删顶层评论时要按它找回复一并清掉) |
|
||||
| `settings` | 键值配置与缓存 | `name`(唯一)、`value`。存存储驱动选择、各云厂商配置、图片访问方式等;**云厂商密钥以 `enc:v1:gcm:` 前缀密文落库**(密钥文件 `db/.settings.key`,首次保存时自动生成,**丢失后密文不可恢复**) |
|
||||
|
||||
`settings` 中除配置外的运行时缓存键(都可安全清空,程序会自动重建):
|
||||
@@ -265,16 +270,17 @@ server {
|
||||
2. 进入「年度报告」,页面顶部切换年份即可查看。
|
||||
3. 未绑定观影人时报告为空并给出提示。
|
||||
|
||||
### 打分、写评价与参与讨论
|
||||
### 打分、写评价与参与评论
|
||||
|
||||
1. 打开任意一部电影的详情页,滚到最下面的「💬 评价与讨论」。
|
||||
2. **打分**:点「我的评分」后面的 1-10 任意一个数字(选中会变橙色),旁边实时显示「几分 · 怎么样」。
|
||||
3. **写评价**:在下面文本框里写正文;再到「我看完的感受(可多选)」里勾几个感受 emoji,点「📝 发表评价」。
|
||||
- 已经评价过时,这一块会变成「✏️ 修改我的评价」,按钮是「💾 保存修改」——同一部电影不会留下两条你的评价,**原来勾的感受会自动带上**,取消勾选就是清掉。
|
||||
1. 打开任意一部电影的详情页,滚到最下面的「💬 评价与评论」。**这一块默认全是内容、没有一个输入框**,想写什么就点哪个按钮。
|
||||
2. **写评价**:没评价过时,点顶部的「📝 写评价」展开表单 → 点「我的评分」后面的 1-10 任意一个数字(选中会变橙色,旁边实时显示「几分 · 怎么样」)→ 写正文 → 在「我看完的感受(可多选)」里勾几个感受 emoji → 点「📝 发表评价」。
|
||||
- 评价过之后「📝 写评价」就没了,改成在**自己那条评价下面**出现「✏️ 修改我的评价」,点开同样是这套表单,按钮变成「💾 保存修改」;同一部电影不会留下两条你的评价,**原来勾的感受会自动带上**,取消勾选就是清掉。
|
||||
- 评价保存后,感受会以只读小标签显示在这条评价正文下面。
|
||||
4. **参与讨论**:在别人的(或自己的)任意一条评价下面,写一句话,同样可以勾选感受,点「💬 发表讨论」。
|
||||
- 讨论会带上评价人的头像、昵称,以及你选的感受 emoji。
|
||||
5. **删除**:自己发的评价和讨论,右侧有删除按钮(别人的没有);删评价时下面的讨论会一起删掉。
|
||||
3. **评论**:在任意一条评价下面点「💬 评论」,展开输入框,可以勾选感受,点「💬 发表评论」。
|
||||
4. **回复评论**:在任意一条评论下面点「↩ 回复」,展开输入框,点「↩ 发表回复」。
|
||||
- 页面在回复上方标出「回复 @某某」。**回复的回复也只会平铺在同一层**,不会一层层往右缩进。
|
||||
- 评论会带上作者的头像、昵称,以及你选的感受 emoji。
|
||||
5. **删除**:自己发的评价和评论,右侧有删除按钮(别人的没有);删一条顶层评论时它下面的回复会一起删掉,删评价时下面的评论全部删掉。
|
||||
|
||||
> 评价人是**登录账号**。家里几个人各有一个账号时,同一部电影可以各自打分,标题栏会显示「N 条评价 · 均分 X」。
|
||||
|
||||
|
||||
+305
-125
@@ -36,8 +36,9 @@ $rvError = ''; // 评价表单的错误
|
||||
$rvFormRating = 0;
|
||||
$rvFormContent = '';
|
||||
$rvFormFeelings = []; // 评价表单里已勾选的观后感受
|
||||
$rvCommentErrId = 0; // 哪一条评价的讨论表单报错
|
||||
$rvCommentErr = '';
|
||||
$rvCommentErrId = 0; // 哪一条评价下的「评论」表单报错
|
||||
$rvReplyErrId = 0; // 哪一条评论下的「回复」表单报错
|
||||
$rvCommentErr = ''; // 上面两种表单共用的错误文案
|
||||
|
||||
// 当前账号已有的评价(有则表单变成「修改」)
|
||||
$myReview = getMyReview($id, $adminId);
|
||||
@@ -88,36 +89,44 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $adminId > 0) {
|
||||
|
||||
} elseif ($rvAction === 'add_comment') {
|
||||
$reviewId = (int)($_POST['review_id'] ?? 0);
|
||||
$parentId = (int)($_POST['parent_id'] ?? 0); // 0 = 评论这条评价;>0 = 回复某条评论
|
||||
$content = trim((string)($_POST['content'] ?? ''));
|
||||
$feelings = normalizeFeelings($_POST['feelings'] ?? []);
|
||||
$review = getReviewById($reviewId);
|
||||
$isReply = ($parentId > 0);
|
||||
|
||||
if ($content === '') {
|
||||
$rvCommentErrId = $reviewId;
|
||||
$rvCommentErr = '讨论内容不能为空';
|
||||
$rvCommentErr = $isReply ? '回复内容不能为空' : '评论内容不能为空';
|
||||
} elseif (reviewTextLength($content) > REVIEW_COMMENT_MAX) {
|
||||
$rvCommentErrId = $reviewId;
|
||||
$rvCommentErr = '讨论内容太长了(最多 ' . REVIEW_COMMENT_MAX . ' 字)';
|
||||
$rvCommentErr = ($isReply ? '回复' : '评论') . '内容太长了(最多 ' . REVIEW_COMMENT_MAX . ' 字)';
|
||||
} elseif (!$review || (int)$review['movie_id'] !== $id) {
|
||||
$rvCommentErrId = $reviewId;
|
||||
$rvCommentErr = '这条评价不存在';
|
||||
} else {
|
||||
$newId = addReviewComment($reviewId, $adminId, $content, $feelings);
|
||||
$newId = addReviewComment($reviewId, $adminId, $content, $feelings, $parentId);
|
||||
if ($newId) {
|
||||
$_SESSION['rv_flash'] = ['type' => 'success', 'msg' => '讨论已发表'];
|
||||
$_SESSION['rv_flash'] = ['type' => 'success', 'msg' => $isReply ? '回复已发表' : '评论已发表'];
|
||||
header('Location: detail.php?id=' . $id . '#reviews');
|
||||
exit;
|
||||
}
|
||||
$rvCommentErr = ($isReply && !getReviewCommentById($parentId))
|
||||
? '要回复的那条评论不存在'
|
||||
: '保存失败,请稍后再试';
|
||||
}
|
||||
|
||||
// 提示挂回它来自的那个表单:渲染时命中就把 $rvCommentErrShown 置位;
|
||||
// 若那个表单根本不在页面上(比如被改过的 id),末尾会退化成区块顶部的提示。
|
||||
if ($isReply) {
|
||||
$rvReplyErrId = $parentId;
|
||||
} else {
|
||||
$rvCommentErrId = $reviewId;
|
||||
$rvCommentErr = '保存失败,请稍后再试';
|
||||
}
|
||||
|
||||
} elseif ($rvAction === 'delete_comment') {
|
||||
$commentId = (int)($_POST['comment_id'] ?? 0);
|
||||
if (deleteReviewComment($commentId, $adminId)) {
|
||||
$_SESSION['rv_flash'] = ['type' => 'success', 'msg' => '讨论已删除'];
|
||||
$_SESSION['rv_flash'] = ['type' => 'success', 'msg' => '评论已删除'];
|
||||
} else {
|
||||
$_SESSION['rv_flash'] = ['type' => 'error', 'msg' => '只能删除自己的讨论'];
|
||||
$_SESSION['rv_flash'] = ['type' => 'error', 'msg' => '只能删除自己的评论'];
|
||||
}
|
||||
header('Location: detail.php?id=' . $id . '#reviews');
|
||||
exit;
|
||||
@@ -128,18 +137,128 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $adminId > 0) {
|
||||
$reviews = getMovieReviews($id);
|
||||
$rvStats = reviewStats($id);
|
||||
$feelings = feelingCatalog();
|
||||
// 讨论的错误提示默认贴在那条评价自己的表单上;但如果出错的评价根本不在这部电影
|
||||
// 下(比如被改过的 review_id),页面上找不到对应的表单,就退化成整段顶部的提示,
|
||||
// 否则错误会被静默吞掉。
|
||||
// 评论 / 回复的错误提示默认贴在它来自的那个表单上。到底贴上了没有,由渲染过程
|
||||
// 自己记录(渲染到那个表单时把 $rvCommentErrShown 置为 true)——这样不必事先遍历
|
||||
// 判断,也不会漏掉「回复表单」这种情况。若到最后都没贴上(那个表单不在页面上,
|
||||
// 比如被改过的 review_id / comment_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));
|
||||
|
||||
// ---------- 评价区复用的模板片段 ----------
|
||||
// 交互原则:页面上默认不出现任何输入框,要写什么就点对应那个按钮,表单当场展开;
|
||||
// 提交后页面重新加载,表单自然又收起来。折叠用原生 <details>/<summary>:
|
||||
// 不依赖 JS、没有 JS 也能用,键盘也能操作。
|
||||
// 写成闭包是为了让「评价表单」「评论/回复表单」各只有一份代码,却能出现在多个位置
|
||||
// (评价表单要在「写评价」和「修改我的评价」两处复用)。
|
||||
|
||||
// 可勾选的观后感受;$checked 里的是要预勾选的
|
||||
$feelChips = function (array $checked = []) use ($feelings) {
|
||||
ob_start(); ?>
|
||||
<div class="feel-chips">
|
||||
<?php foreach ($feelings as $fkey => $f): ?>
|
||||
<label class="feel-chip feel-pick" title="<?= h($f['label']) ?>">
|
||||
<input type="checkbox" name="feelings[]" value="<?= h($fkey) ?>"<?= in_array($fkey, $checked, true) ? ' checked' : '' ?>>
|
||||
<span class="feel-inner">
|
||||
<span class="feel-emoji"><?= $f['emoji'] ?></span><span class="feel-label"><?= h($f['label']) ?></span>
|
||||
</span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php return ob_get_clean();
|
||||
};
|
||||
|
||||
// 只读的观后感受标签(评价/评论已保存的感受)
|
||||
$feelTags = function (array $keys) {
|
||||
if (empty($keys)) return '';
|
||||
ob_start(); ?>
|
||||
<div class="rv-feelings">
|
||||
<?php foreach ($keys as $fkey): ?>
|
||||
<?php $f = feelingInfo($fkey); if (!$f) continue; ?>
|
||||
<span class="feel-chip is-static" title="观后感受:<?= h($f['label']) ?>">
|
||||
<span class="feel-emoji"><?= $f['emoji'] ?></span><span class="feel-label"><?= h($f['label']) ?></span>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php return ob_get_clean();
|
||||
};
|
||||
|
||||
// 我的评价表单(打分 + 正文 + 感受),外面套一层 details:
|
||||
// 未评价时按钮写「写评价」,已评价时写「修改我的评价」。
|
||||
// 注意:整页只会渲染出这一个表单,所以里面的 id 不会重复。
|
||||
$rvFormHtml = function ($summary, $open = false) use ($id, $myReview, $rvFormRating, $rvFormContent, $rvFormFeelings, $feelChips, $rvError) {
|
||||
ob_start(); ?>
|
||||
<details class="rv-composer"<?= $open ? ' open' : '' ?>>
|
||||
<summary class="rv-open-btn"><?= $summary ?></summary>
|
||||
<form method="post" action="#reviews" class="rv-form" id="reviewForm">
|
||||
<input type="hidden" name="action" value="save_review">
|
||||
<input type="hidden" name="movie_id" value="<?= $id ?>">
|
||||
<div class="rv-rating-row">
|
||||
<span class="rv-rating-label">我的评分</span>
|
||||
<div class="rv-scale" role="radiogroup" aria-label="评分,1 分到 10 分">
|
||||
<?php for ($i = 1; $i <= 10; $i++): ?>
|
||||
<label class="rv-num" title="<?= $i ?> 分 · <?= h(ratingWord($i)) ?>">
|
||||
<input type="radio" name="rating" value="<?= $i ?>"<?= $rvFormRating === $i ? ' checked' : '' ?>>
|
||||
<span><?= $i ?></span>
|
||||
</label>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<span class="rv-word<?= $rvFormRating > 0 ? ' is-set' : '' ?>" id="rvWord">
|
||||
<?= $rvFormRating > 0 ? $rvFormRating . ' 分 · ' . h(ratingWord($rvFormRating)) : '未评分' ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php if ($rvError): ?>
|
||||
<div class="rv-error"><?= h($rvError) ?></div>
|
||||
<?php endif; ?>
|
||||
<textarea name="content" class="rv-textarea" maxlength="<?= REVIEW_CONTENT_MAX ?>" required
|
||||
placeholder="这部电影怎么样?剧情、表演、画面、音效……随便聊聊"><?= h($rvFormContent) ?></textarea>
|
||||
<div class="rv-feeling-pick">
|
||||
<span class="rv-pick-label">我看完的感受(可多选)</span>
|
||||
<?= $feelChips($rvFormFeelings) ?>
|
||||
</div>
|
||||
<div class="rv-form-foot">
|
||||
<span class="rv-hint">每个账号对每部电影只有一条评价,再次提交即为修改。</span>
|
||||
<button type="submit" class="btn btn-primary btn-sm"><?= $myReview ? '💾 保存修改' : '📝 发表评价' ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
<?php return ob_get_clean();
|
||||
};
|
||||
|
||||
// 评论 / 回复表单。$parentId = 0 是对这条评价发评论;>0 是回复 $parentId 那条评论
|
||||
// (底层会不会再往下嵌由 addReviewComment 规范化,页面上永远只有两层)。
|
||||
// 渲染命中错误时把 $rvCommentErrShown 置位,末尾就不必再兜底显示一遍。
|
||||
$rvCommentBox = function ($reviewId, $parentId, $summary, $open = false) use ($id, $feelChips, $rvCommentErr, $rvCommentErrId, $rvReplyErrId, &$rvCommentErrShown) {
|
||||
$parentId = (int)$parentId;
|
||||
$errHere = $parentId > 0
|
||||
? ($rvReplyErrId === $parentId)
|
||||
: ($rvCommentErrId === (int)$reviewId);
|
||||
ob_start(); ?>
|
||||
<details class="rv-composer rv-inline"<?= $open ? ' open' : '' ?>>
|
||||
<summary class="rv-act-btn"><?= $summary ?></summary>
|
||||
<form method="post" action="#reviews" class="rv-form rv-form-inline">
|
||||
<input type="hidden" name="action" value="add_comment">
|
||||
<input type="hidden" name="movie_id" value="<?= $id ?>">
|
||||
<input type="hidden" name="review_id" value="<?= (int)$reviewId ?>">
|
||||
<input type="hidden" name="parent_id" value="<?= $parentId ?>">
|
||||
<?php if ($rvCommentErr !== '' && $errHere): ?>
|
||||
<?php $rvCommentErrShown = true; ?>
|
||||
<div class="rv-error"><?= h($rvCommentErr) ?></div>
|
||||
<?php endif; ?>
|
||||
<textarea name="content" class="rv-textarea rv-textarea-sm" maxlength="<?= REVIEW_COMMENT_MAX ?>" required
|
||||
placeholder="<?= $parentId > 0 ? '回复这一条…' : '说说你的看法…' ?>"></textarea>
|
||||
<div class="rv-feeling-pick">
|
||||
<span class="rv-pick-label">观后感受(可多选)</span>
|
||||
<?= $feelChips() ?>
|
||||
</div>
|
||||
<div class="rv-form-foot">
|
||||
<button type="submit" class="btn btn-outline btn-sm"><?= $parentId > 0 ? '↩ 发表回复' : '💬 发表评论' ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
<?php return ob_get_clean();
|
||||
};
|
||||
|
||||
// ---------- 灯箱图片序列:海报 → 票根 → 观影照片 ----------
|
||||
// 以前每张图都是用 JS 的 window.open 弹到新窗口,没有关闭按钮、体验很差。
|
||||
// 现在把所有图片收进这一个序列:点任意一张都在当前页弹出查看,
|
||||
@@ -438,12 +557,6 @@ $hasGallery = !empty($gallery);
|
||||
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;
|
||||
@@ -694,9 +807,6 @@ $hasGallery = !empty($gallery);
|
||||
.rv-item > .rv-feelings {
|
||||
margin-left: 44px;
|
||||
}
|
||||
.rv-comment-form {
|
||||
margin-left: 44px;
|
||||
}
|
||||
.rv-feeling-pick {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -764,14 +874,116 @@ $hasGallery = !empty($gallery);
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---------- 按需展开的输入框(原生 details,不依赖 JS) ----------
|
||||
页面上默认看不到任何 textarea:写评价点「写评价」、评论点「评论」、
|
||||
回复点「回复」,点开才展开;提交后页面刷新,自然又收起来。 */
|
||||
.rv-composer > summary {
|
||||
list-style: none; /* 去掉默认的三角标记(标准属性) */
|
||||
cursor: pointer;
|
||||
}
|
||||
.rv-composer > summary::-webkit-details-marker {
|
||||
display: none; /* 同上,兼容 WebKit / 旧 Chrome */
|
||||
}
|
||||
/* 评价表单的主按钮(那两处入口共用) */
|
||||
.rv-open-btn {
|
||||
display: inline-block;
|
||||
padding: 8px 18px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.rv-open-btn:hover {
|
||||
box-shadow: 0 4px 14px rgba(230, 126, 34, 0.32);
|
||||
}
|
||||
/* 展开状态下按钮退成浅色,避免和下方表单抢注意力 */
|
||||
.rv-composer[open] > .rv-open-btn {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
box-shadow: none;
|
||||
}
|
||||
/* 列表内的轻量按钮:评论 / 回复 */
|
||||
.rv-act-btn {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
background: var(--input-bg, var(--card-bg));
|
||||
color: var(--text-secondary);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.rv-act-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
.rv-composer[open] > .rv-act-btn {
|
||||
border-color: var(--primary);
|
||||
background: rgba(230, 126, 34, 0.12);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 展开出来的表单:贴紧按钮,且不再要外面那圈大下边距 */
|
||||
.rv-composer > .rv-form {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.rv-form-inline {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.rv-form-inline .rv-textarea-sm {
|
||||
min-height: 56px;
|
||||
}
|
||||
/* 评价卡片底部的操作行 */
|
||||
.rv-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: 12px 0 0 44px;
|
||||
}
|
||||
/* 一条评论 / 一条回复下面的「回复」按钮 */
|
||||
.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(--text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.rv-at span {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.rv-content,
|
||||
.rv-updated,
|
||||
.rv-comments,
|
||||
.rv-comment-form,
|
||||
.rv-actions,
|
||||
.rv-item > .rv-feelings {
|
||||
margin-left: 0;
|
||||
}
|
||||
/* 手机上回复再收一点缩进,别把内容挤窄了 */
|
||||
.rv-comment-reply {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 图片灯箱(替代原来的 window.open 弹窗) ----------
|
||||
@@ -1040,11 +1252,13 @@ $hasGallery = !empty($gallery);
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 评价与讨论
|
||||
只要有登录账号就能打分(1-10)+ 写评价,并可以在任意一条评价下面参与讨论;
|
||||
评价人是「账号」,与「观影人」无关——没去看过的人也能评。 -->
|
||||
<!-- 评价与评论
|
||||
只要有登录账号就能打分(1-10)+ 写评价,并可以在任意一条评价 / 评论下面回复;
|
||||
评价人是「账号」,与「观影人」无关——没去看过的人也能评。
|
||||
页面上默认不出现任何输入框:写评价要点「写评价」、评论要点「评论」、
|
||||
回复要点「回复」,点开才展开,提交完页面刷新就自动收起来了。 -->
|
||||
<div class="detail-section" id="reviews">
|
||||
<h3>💬 评价与讨论
|
||||
<h3>💬 评价与评论
|
||||
<span class="badge">
|
||||
<?php if ($rvStats['count'] > 0): ?>
|
||||
<?= $rvStats['count'] ?> 条评价 · 均分 <?= $rvStats['avg'] ?>
|
||||
@@ -1060,51 +1274,13 @@ $hasGallery = !empty($gallery);
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 我的评分 + 评价(已评过时这里就是「修改」) -->
|
||||
<form method="post" action="#reviews" class="rv-form" id="reviewForm">
|
||||
<input type="hidden" name="action" value="save_review">
|
||||
<input type="hidden" name="movie_id" value="<?= $id ?>">
|
||||
<div class="rv-form-head"><?= $myReview ? '✏️ 修改我的评价' : '⭐ 写下我的评价' ?></div>
|
||||
<div class="rv-rating-row">
|
||||
<span class="rv-rating-label">我的评分</span>
|
||||
<div class="rv-scale" role="radiogroup" aria-label="评分,1 分到 10 分">
|
||||
<?php for ($i = 1; $i <= 10; $i++): ?>
|
||||
<label class="rv-num" title="<?= $i ?> 分 · <?= h(ratingWord($i)) ?>">
|
||||
<input type="radio" name="rating" value="<?= $i ?>"<?= $rvFormRating === $i ? ' checked' : '' ?>>
|
||||
<span><?= $i ?></span>
|
||||
</label>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<span class="rv-word<?= $rvFormRating > 0 ? ' is-set' : '' ?>" id="rvWord">
|
||||
<?= $rvFormRating > 0 ? $rvFormRating . ' 分 · ' . h(ratingWord($rvFormRating)) : '未评分' ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php if ($rvError): ?>
|
||||
<div class="rv-error"><?= h($rvError) ?></div>
|
||||
<!-- 没评价过:只显示一个「写评价」按钮,点开才出现输入框;评价完这个按钮就没了 -->
|
||||
<?php if (!$myReview): ?>
|
||||
<?= $rvFormHtml('📝 写评价', $rvError !== '') ?>
|
||||
<?php endif; ?>
|
||||
<textarea name="content" class="rv-textarea" maxlength="<?= REVIEW_CONTENT_MAX ?>"
|
||||
placeholder="这部电影怎么样?剧情、表演、画面、音效……随便聊聊"><?= h($rvFormContent) ?></textarea>
|
||||
<div class="rv-feeling-pick">
|
||||
<span class="rv-pick-label">我看完的感受(可多选)</span>
|
||||
<div class="feel-chips">
|
||||
<?php foreach ($feelings as $fkey => $f): ?>
|
||||
<label class="feel-chip feel-pick" title="<?= h($f['label']) ?>">
|
||||
<input type="checkbox" name="feelings[]" value="<?= h($fkey) ?>"<?= in_array($fkey, $rvFormFeelings, true) ? ' checked' : '' ?>>
|
||||
<span class="feel-inner">
|
||||
<span class="feel-emoji"><?= $f['emoji'] ?></span><span class="feel-label"><?= h($f['label']) ?></span>
|
||||
</span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rv-form-foot">
|
||||
<span class="rv-hint">每个账号对每部电影只有一条评价,再次提交即为修改。</span>
|
||||
<button type="submit" class="btn btn-primary btn-sm"><?= $myReview ? '💾 保存修改' : '📝 发表评价' ?></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (empty($reviews)): ?>
|
||||
<p class="rv-empty">还没有人评价这部电影,你可以是第一个 👆</p>
|
||||
<p class="rv-empty">还没有评价,等你来评一个。</p>
|
||||
<?php else: ?>
|
||||
<div class="rv-list">
|
||||
<?php foreach ($reviews as $rv): ?>
|
||||
@@ -1124,7 +1300,7 @@ $hasGallery = !empty($gallery);
|
||||
<span class="rv-score" title="<?= h(ratingWord($rvRating)) ?>"><?= $rvRating ?><span class="rv-score-max">/10</span></span>
|
||||
<?php if ($rvMine): ?>
|
||||
<form method="post" action="#reviews" class="rv-del"
|
||||
onsubmit="return confirm('删除我的这条评价?下面的讨论也会一起删除,不可恢复。')">
|
||||
onsubmit="return confirm('删除我的这条评价?下面的评论也会一起删除,不可恢复。')">
|
||||
<input type="hidden" name="action" value="delete_review">
|
||||
<input type="hidden" name="movie_id" value="<?= $id ?>">
|
||||
<input type="hidden" name="review_id" value="<?= $rv['id'] ?>">
|
||||
@@ -1133,27 +1309,20 @@ $hasGallery = !empty($gallery);
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="rv-content"><?= h($rv['content']) ?></div>
|
||||
<?php if (!empty($rv['feelings'])): ?>
|
||||
<div class="rv-feelings">
|
||||
<?php foreach ($rv['feelings'] as $fkey): ?>
|
||||
<?php $f = feelingInfo($fkey); if (!$f) continue; ?>
|
||||
<span class="feel-chip is-static" title="观后感受:<?= h($f['label']) ?>">
|
||||
<span class="feel-emoji"><?= $f['emoji'] ?></span><span class="feel-label"><?= h($f['label']) ?></span>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?= $feelTags($rv['feelings']) ?>
|
||||
<?php if (!empty($rv['updated_at']) && $rv['updated_at'] !== $rv['created_at']): ?>
|
||||
<div class="rv-updated">已编辑 <?= h(substr((string)$rv['updated_at'], 0, 16)) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($rv['comments'])): ?>
|
||||
<div class="rv-comments">
|
||||
<?php foreach ($rv['comments'] as $c): ?>
|
||||
<?php foreach ($rv['comments'] as $thread): ?>
|
||||
<?php
|
||||
$c = $thread['comment'];
|
||||
$cMine = ((int)$c['admin_id'] === $adminId);
|
||||
$cName = ($c['username'] !== null && $c['username'] !== '') ? $c['username'] : '已注销账号';
|
||||
?>
|
||||
<div class="rv-thread">
|
||||
<div class="rv-comment">
|
||||
<img class="rv-avatar rv-avatar-sm" src="<?= getAvatarUrl($c['avatar'] ?? null, $cName) ?>" alt="">
|
||||
<div class="rv-c-body">
|
||||
@@ -1163,59 +1332,70 @@ $hasGallery = !empty($gallery);
|
||||
<span class="rv-time"><?= h(substr((string)$c['created_at'], 0, 16)) ?></span>
|
||||
<?php if ($cMine): ?>
|
||||
<form method="post" action="#reviews" class="rv-del"
|
||||
onsubmit="return confirm('删除我的这条讨论?')">
|
||||
onsubmit="return confirm('删除我的这条评论?它下面的回复也会一起删除。')">
|
||||
<input type="hidden" name="action" value="delete_comment">
|
||||
<input type="hidden" name="movie_id" value="<?= $id ?>">
|
||||
<input type="hidden" name="comment_id" value="<?= $c['id'] ?>">
|
||||
<button type="submit" class="rv-del-btn" title="删除我的讨论" aria-label="删除我的讨论">✕</button>
|
||||
<button type="submit" class="rv-del-btn" title="删除我的评论" aria-label="删除我的评论">✕</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if (!empty($c['feelings'])): ?>
|
||||
<div class="rv-feelings">
|
||||
<?php foreach ($c['feelings'] as $fkey): ?>
|
||||
<?php $f = feelingInfo($fkey); if (!$f) continue; ?>
|
||||
<span class="feel-chip is-static" title="观后感受:<?= h($f['label']) ?>">
|
||||
<span class="feel-emoji"><?= $f['emoji'] ?></span><span class="feel-label"><?= h($f['label']) ?></span>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?= $feelTags($c['feelings']) ?>
|
||||
<div class="rv-c-text"><?= h($c['content']) ?></div>
|
||||
<div class="rv-c-acts">
|
||||
<?= $rvCommentBox($rv['id'], $c['id'], '↩ 回复', $rvReplyErrId === (int)$c['id']) ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 回复一律平铺在所属那条评论之下(不再逐层缩进),只用「回复 @谁」标出来 -->
|
||||
<?php foreach ($thread['replies'] as $rep): ?>
|
||||
<?php
|
||||
$rMine = ((int)$rep['admin_id'] === $adminId);
|
||||
$rName = ($rep['username'] !== null && $rep['username'] !== '') ? $rep['username'] : '已注销账号';
|
||||
?>
|
||||
<div class="rv-comment rv-comment-reply">
|
||||
<img class="rv-avatar rv-avatar-sm" src="<?= getAvatarUrl($rep['avatar'] ?? null, $rName) ?>" alt="">
|
||||
<div class="rv-c-body">
|
||||
<div class="rv-c-head">
|
||||
<span class="rv-name"><?= h($rName) ?></span>
|
||||
<?php if ($rMine): ?><span class="rv-mine">我</span><?php endif; ?>
|
||||
<span class="rv-time"><?= h(substr((string)$rep['created_at'], 0, 16)) ?></span>
|
||||
<?php if ($rMine): ?>
|
||||
<form method="post" action="#reviews" class="rv-del"
|
||||
onsubmit="return confirm('删除我的这条回复?')">
|
||||
<input type="hidden" name="action" value="delete_comment">
|
||||
<input type="hidden" name="movie_id" value="<?= $id ?>">
|
||||
<input type="hidden" name="comment_id" value="<?= $rep['id'] ?>">
|
||||
<button type="submit" class="rv-del-btn" title="删除我的回复" aria-label="删除我的回复">✕</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if (!empty($rep['reply_to_username'])): ?>
|
||||
<div class="rv-at">回复 <span>@<?= h($rep['reply_to_username']) ?></span></div>
|
||||
<?php endif; ?>
|
||||
<?= $feelTags($rep['feelings']) ?>
|
||||
<div class="rv-c-text"><?= h($rep['content']) ?></div>
|
||||
<div class="rv-c-acts">
|
||||
<?= $rvCommentBox($rv['id'], $rep['id'], '↩ 回复', $rvReplyErrId === (int)$rep['id']) ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 在这条评价下参与讨论:可多选观后感受 -->
|
||||
<form method="post" action="#reviews" class="rv-comment-form">
|
||||
<input type="hidden" name="action" value="add_comment">
|
||||
<input type="hidden" name="movie_id" value="<?= $id ?>">
|
||||
<input type="hidden" name="review_id" value="<?= $rv['id'] ?>">
|
||||
<?php if ($rvCommentErrId === (int)$rv['id']): ?>
|
||||
<div class="rv-error"><?= h($rvCommentErr) ?></div>
|
||||
<!-- 操作行:默认都只是按钮,点开才出现输入框;
|
||||
已评价过的用户,在「我这条评价」下面多一个「修改」 -->
|
||||
<div class="rv-actions">
|
||||
<?= $rvCommentBox($rv['id'], 0, '💬 评论', $rvCommentErrId === (int)$rv['id']) ?>
|
||||
<?php if ($rvMine): ?>
|
||||
<?= $rvFormHtml('✏️ 修改我的评价', $rvError !== '') ?>
|
||||
<?php endif; ?>
|
||||
<textarea name="content" class="rv-textarea rv-textarea-sm" maxlength="<?= REVIEW_COMMENT_MAX ?>"
|
||||
placeholder="说说你的看法…"></textarea>
|
||||
<div class="rv-feeling-pick">
|
||||
<span class="rv-pick-label">看完的感受(可多选)</span>
|
||||
<div class="feel-chips">
|
||||
<?php foreach ($feelings as $fkey => $f): ?>
|
||||
<label class="feel-chip feel-pick" title="<?= h($f['label']) ?>">
|
||||
<input type="checkbox" name="feelings[]" value="<?= h($fkey) ?>">
|
||||
<span class="feel-inner">
|
||||
<span class="feel-emoji"><?= $f['emoji'] ?></span><span class="feel-label"><?= h($f['label']) ?></span>
|
||||
</span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rv-form-foot">
|
||||
<button type="submit" class="btn btn-outline btn-sm">💬 发表讨论</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
+19
-1
@@ -119,7 +119,13 @@ function initDB() {
|
||||
)
|
||||
");
|
||||
|
||||
// 评价下的讨论(评论)。
|
||||
// 评价下的讨论(评论)。评论是**两级结构**:
|
||||
// parent_id 为空 → 顶层评论,直接挂在某条评价下;
|
||||
// parent_id 非空 → 对某条评论的回复,值指向「被回复的那条评论」的 id。
|
||||
// 页面展示时所有回复都平铺在它所属的顶层评论之下(不再逐层缩进),
|
||||
// 只把「回复 @谁」写出来;这样无论回复多深,视觉上都只有两层。
|
||||
// reply_to_admin_id → 被回复者(admins.id),用于显示「回复 @某某」。
|
||||
// 它存「人」而不是「评论 id」,所以对方那条评论被删掉也不会留下悬空引用。
|
||||
// feelings 存的是感受 key 的 JSON 数组(如 [\"happy\",\"sad\"]),
|
||||
// emoji 与文案不入库——统一在 lib/reviews.php 的目录里维护,改文案不用动数据。
|
||||
$db->exec("
|
||||
@@ -127,6 +133,8 @@ function initDB() {
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
review_id INTEGER NOT NULL,
|
||||
admin_id INTEGER NOT NULL,
|
||||
parent_id INTEGER,
|
||||
reply_to_admin_id INTEGER,
|
||||
content TEXT NOT NULL,
|
||||
feelings TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -150,6 +158,16 @@ function initDB() {
|
||||
try { $db->exec("ALTER TABLE admins ADD COLUMN person_id INTEGER REFERENCES persons(id) ON DELETE SET NULL"); } catch (PDOException $e) {}
|
||||
// 新增:评价本身也可以带观后感受(评价表先于该字段上线过一版,故需要迁移)
|
||||
try { $db->exec("ALTER TABLE reviews ADD COLUMN feelings TEXT"); } catch (PDOException $e) {}
|
||||
// 新增:评论支持回复(两级结构,含义见上面 review_comments 建表处的说明)。
|
||||
// 评论表同样先于这两个字段上线过一版,线上是老结构,必须能自动补列。
|
||||
try { $db->exec("ALTER TABLE review_comments ADD COLUMN parent_id INTEGER"); } catch (PDOException $e) {}
|
||||
try { $db->exec("ALTER TABLE review_comments ADD COLUMN reply_to_admin_id INTEGER"); } catch (PDOException $e) {}
|
||||
|
||||
// 删顶层评论时要按 parent_id 找它下面的回复一并清掉,给它建个索引。
|
||||
// 这条索引必须放在上面的「补列」之后:老库的 review_comments 是旧结构、没有
|
||||
// parent_id 列,而上面建表用的是 IF NOT EXISTS(对已存在的表不生效),
|
||||
// 索引若跑在补列之前会直接抛 "no such column: parent_id" 让整个 initDB 挂掉。
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_review_comments_parent ON review_comments(parent_id)");
|
||||
|
||||
// ===== 迁移旧数据 =====
|
||||
// 将 viewers.name 迁移到 persons
|
||||
|
||||
+93
-12
@@ -129,7 +129,12 @@ function getMovieReviews($movie_id) {
|
||||
return $reviews;
|
||||
}
|
||||
|
||||
// 给一批评价挂上 comments(一次查询,避免每条评价查一次库)
|
||||
// 给一批评价挂上 comments(一次查询,避免每条评价查一次库)。
|
||||
//
|
||||
// 评论是**两级结构**:顶层评论(parent_id 为空)直接挂在评价下;
|
||||
// 对评论的回复统一平铺在它所属的顶层评论的 replies 里——页面上不再逐层缩进,
|
||||
// 只用 reply_to_username 标出「回复 @谁」。这样无论来回回复多少轮,
|
||||
// 视觉上永远只有两层,页面也干净。
|
||||
function attachCommentsToReviews(array &$reviews) {
|
||||
if (empty($reviews)) return;
|
||||
$ids = array_column($reviews, 'id');
|
||||
@@ -139,21 +144,56 @@ function attachCommentsToReviews(array &$reviews) {
|
||||
$db = getDB();
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$stmt = $db->prepare("
|
||||
SELECT c.*, a.username, a.avatar
|
||||
SELECT c.*, a.username, a.avatar, ra.username AS reply_to_username
|
||||
FROM review_comments c
|
||||
LEFT JOIN admins a ON c.admin_id = a.id
|
||||
LEFT JOIN admins ra ON c.reply_to_admin_id = ra.id
|
||||
WHERE c.review_id IN ($placeholders)
|
||||
ORDER BY c.created_at ASC, c.id ASC
|
||||
");
|
||||
$stmt->execute($ids);
|
||||
|
||||
$map = [];
|
||||
$byId = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$row['feelings'] = parseStoredFeelings($row['feelings'] ?? '');
|
||||
$map[(int)$row['review_id']][] = $row;
|
||||
// 老数据里这两列可能还不存在(库刚迁移)或为 0,统一成「空 = null」
|
||||
$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' => [回复…]], … ]
|
||||
foreach ($byId as $row) {
|
||||
if ($row['parent_id'] !== null) continue;
|
||||
$grouped[(int)$row['review_id']][] = ['comment' => $row, 'replies' => []];
|
||||
}
|
||||
// 顶层评论 id => 它在 $grouped 里的下标,便于把回复挂上去
|
||||
$index = [];
|
||||
foreach ($grouped as $rvId => $list) {
|
||||
foreach ($list as $i => $item) {
|
||||
$index[$rvId][(int)$item['comment']['id']] = $i;
|
||||
}
|
||||
}
|
||||
|
||||
// 再挂回复:沿 parent 链向上找到所属的顶层评论。
|
||||
// 正常写入时 parent_id 一定就是顶层评论 id(addReviewComment 会规范化),
|
||||
// 这里多走几步只是容忍被手工改坏的数据,最多 20 步防成环。
|
||||
foreach ($byId as $row) {
|
||||
if ($row['parent_id'] === null) continue;
|
||||
$rvId = (int)$row['review_id'];
|
||||
$root = (int)$row['parent_id'];
|
||||
for ($step = 0; $step < 20 && isset($byId[$root]) && $byId[$root]['parent_id'] !== null; $step++) {
|
||||
$root = $byId[$root]['parent_id'];
|
||||
}
|
||||
if (isset($index[$rvId][$root])) {
|
||||
$grouped[$rvId][$index[$rvId][$root]]['replies'][] = $row;
|
||||
}
|
||||
// 找不到所属顶层(顶层已被删)的回复直接丢掉,不让它变成挂在评价上的孤儿
|
||||
}
|
||||
|
||||
foreach ($reviews as &$review) {
|
||||
$review['comments'] = $map[(int)$review['id']] ?? [];
|
||||
$review['comments'] = $grouped[(int)$review['id']] ?? [];
|
||||
}
|
||||
unset($review);
|
||||
}
|
||||
@@ -180,6 +220,17 @@ function getReviewById($review_id) {
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
// 取单条评论(用于校验「回复的目标」存在、且属于同一条评价)
|
||||
function getReviewCommentById($comment_id) {
|
||||
if ((int)$comment_id <= 0) return null;
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM review_comments WHERE id = ?");
|
||||
$stmt->execute([(int)$comment_id]);
|
||||
// 用 fetchAll 取:只取一行就放着不读完,游标会一直挂着共享读锁(本项目踩过)
|
||||
$rows = $stmt->fetchAll();
|
||||
return $rows ? $rows[0] : null;
|
||||
}
|
||||
|
||||
// 某部电影的评分概况:['count' => n, 'avg' => 7.5]
|
||||
// 没人评价时 avg 为 null,展示时不要显示 0 分。
|
||||
function reviewStats($movie_id) {
|
||||
@@ -281,10 +332,17 @@ function deleteReview($review_id, $admin_id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 在一条评价下发表讨论。$feelings 是感受 key 数组(可为空)。
|
||||
* 在一条评价下发表评论,或回复某条评论。$feelings 是感受 key 数组(可为空)。
|
||||
*
|
||||
* $parent_id = 0 → 直接评论这条评价(成为顶层评论)
|
||||
* $parent_id > 0 → 回复该 id 所指的那条评论(顶层评论或某条回复都行)
|
||||
*
|
||||
* 为了维持「两级平铺」的结构,回复的回复会被**规范化**到同一条顶层评论之下,
|
||||
* 所以库里任何一条评论的 parent_id 要么为空、要么指向一条顶层评论,永远不会更深。
|
||||
*
|
||||
* 返回新评论 id,失败返回 false。
|
||||
*/
|
||||
function addReviewComment($review_id, $admin_id, $content, array $feelings = []) {
|
||||
function addReviewComment($review_id, $admin_id, $content, array $feelings = [], $parent_id = 0) {
|
||||
$db = getDB();
|
||||
$content = trim((string)$content);
|
||||
$review = getReviewById($review_id);
|
||||
@@ -292,25 +350,48 @@ function addReviewComment($review_id, $admin_id, $content, array $feelings = [])
|
||||
return false;
|
||||
}
|
||||
|
||||
$parent_id = (int)$parent_id;
|
||||
$replyTo = null;
|
||||
if ($parent_id > 0) {
|
||||
$parent = getReviewCommentById($parent_id);
|
||||
// 只能回复「同一条评价下」的评论,防止拿别的评价的 comment_id 跨过去
|
||||
if (!$parent || (int)$parent['review_id'] !== (int)$review_id) {
|
||||
return false;
|
||||
}
|
||||
// 回复的回复:挂到那条顶层评论下,@ 的仍然是真正被回复的那个人
|
||||
if ($parent['parent_id'] !== null && (int)$parent['parent_id'] > 0) {
|
||||
$parent_id = (int)$parent['parent_id'];
|
||||
}
|
||||
$replyTo = (int)$parent['admin_id'];
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO review_comments (review_id, admin_id, content, feelings)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO review_comments (review_id, admin_id, parent_id, reply_to_admin_id, content, feelings)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$ok = $stmt->execute([
|
||||
$review_id,
|
||||
$admin_id,
|
||||
$parent_id > 0 ? $parent_id : null,
|
||||
$replyTo,
|
||||
$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;
|
||||
$stmt->execute([(int)$comment_id, (int)$admin_id]);
|
||||
if ($stmt->rowCount() <= 0) return false;
|
||||
|
||||
$stmt = $db->prepare("DELETE FROM review_comments WHERE parent_id = ?");
|
||||
$stmt->execute([(int)$comment_id]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user