新增评价与讨论:打分 1-10 + 观后感受 emoji

- 规则:只要有登录账号就能评价,与「是不是观影人」无关;评价归属 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 秒
This commit is contained in:
2026-09-22 11:42:27 +08:00
parent 7d199d5b3a
commit a2c26ad002
7 changed files with 1081 additions and 2 deletions
+671
View File
@@ -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);
</div>
</div>
<?php endif; ?>
<!-- 评价与讨论
只要有登录账号就能打分(1-10)+ 写评价,并可以在任意一条评价下面参与讨论;
评价人是「账号」,与「观影人」无关——没去看过的人也能评。 -->
<div class="detail-section" id="reviews">
<h3>💬 评价与讨论
<span class="badge">
<?php if ($rvStats['count'] > 0): ?>
<?= $rvStats['count'] ?> 条评价 · 均分 <?= $rvStats['avg'] ?>
<?php else: ?>
还没有评价
<?php endif; ?>
</span>
</h3>
<?php if ($rvFlash): ?>
<div class="rv-flash rv-flash-<?= $rvFlash['type'] === 'success' ? 'success' : 'error' ?>">
<?= h($rvFlash['msg']) ?>
</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 endif; ?>
<textarea name="content" class="rv-textarea" maxlength="<?= REVIEW_CONTENT_MAX ?>"
placeholder="这部电影怎么样?剧情、表演、画面、音效……随便聊聊"><?= h($rvFormContent) ?></textarea>
<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>
<?php else: ?>
<div class="rv-list">
<?php foreach ($reviews as $rv): ?>
<?php
$rvMine = ((int)$rv['admin_id'] === $adminId);
$rvName = ($rv['username'] !== null && $rv['username'] !== '') ? $rv['username'] : '已注销账号';
$rvRating = (int)$rv['rating'];
?>
<div class="rv-item">
<div class="rv-head">
<img class="rv-avatar" src="<?= getAvatarUrl($rv['avatar'] ?? null, $rvName) ?>" alt="">
<div class="rv-who">
<span class="rv-name"><?= h($rvName) ?></span>
<?php if ($rvMine): ?><span class="rv-mine">我</span><?php endif; ?>
<span class="rv-time"><?= h(substr((string)$rv['created_at'], 0, 16)) ?></span>
</div>
<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('删除我的这条评价?下面的讨论也会一起删除,不可恢复。')">
<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'] ?>">
<button type="submit" class="rv-del-btn" title="删除我的评价" aria-label="删除我的评价">🗑️</button>
</form>
<?php endif; ?>
</div>
<div class="rv-content"><?= h($rv['content']) ?></div>
<?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
$cMine = ((int)$c['admin_id'] === $adminId);
$cName = ($c['username'] !== null && $c['username'] !== '') ? $c['username'] : '已注销账号';
?>
<div class="rv-comment">
<img class="rv-avatar rv-avatar-sm" src="<?= getAvatarUrl($c['avatar'] ?? null, $cName) ?>" alt="">
<div class="rv-c-body">
<div class="rv-c-head">
<span class="rv-name"><?= h($cName) ?></span>
<?php if ($cMine): ?><span class="rv-mine">我</span><?php endif; ?>
<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('删除我的这条讨论?')">
<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>
</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; ?>
<div class="rv-c-text"><?= h($c['content']) ?></div>
</div>
</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>
<?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; ?>
<?php if ($rvCommentErr && !$rvCommentErrShown): ?>
<div class="rv-error" style="margin-top: 14px;"><?= h($rvCommentErr) ?></div>
<?php endif; ?>
</div>
</div>
</main>
@@ -679,6 +1319,37 @@ $hasGallery = !empty($gallery);
</script>
<?php endif; ?>
<!-- 打分时实时提示「几分 · 怎么样」。
纯增强:评分用的是原生 radio,没有 JS 也能正常选中并提交。 -->
<script>
(function () {
var WORDS = <?= json_encode(
$rvRatingWords,
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
) ?>;
var form = document.getElementById('reviewForm');
var word = document.getElementById('rvWord');
if (!form || !word) return;
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>
<script src="js/main.js"></script>
</body>
</html>