评价区改成「按钮展开表单」,评论支持两级回复

页面默认不出现任何输入框,要写什么就点对应那个按钮:

- 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:
2026-09-22 16:09:48 +08:00
parent 558c0db576
commit 275bd9d80f
4 changed files with 458 additions and 173 deletions
+95 -14
View File
@@ -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 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;
$row['feelings'] = parseStoredFeelings($row['feelings'] ?? '');
// 老数据里这两列可能还不存在(库刚迁移)或为 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;
}
/**