Files
laoyang ee256adc4b 界面主题:5 套经典配色,按账号记住
系统设置里多一块「🎨 我的界面主题」,点卡片即时预览、保存后全站生效,不用重新登录。

- lib/theme.php(新增):主题注册表 ticket/indigo/forest/wine/midnight,
  分别是「复古票根(暖橙,默认)/ 靛蓝 / 松林 / 绛红 / 暗夜(深色)」。
  currentThemeKey() 以 session 为准,session 里没有就从 admins.theme 补读一次
  —— 升级前就已经登录着的会话也能立刻用上自己账号存的主题。库里是脏值时回落默认主题。
- css/themes.css(新增):5 套调色板,全部以 [data-theme="<key>"] 规则块给出,
  每套 16 个变量一个不少。**必须排在 css/style.css 之后加载**:两者优先级相同
  (都是 0,1,0),靠后出现者胜。另加 --primary-rgb("R, G, B" 三元组),
  页面里的淡色底/光晕一律 rgba(var(--primary-rgb), α),所以加一套主题只需动 CSS,
  页面与 JS 一行都不用改;每套还要声明 color-scheme,否则暗色主题下原生控件
  (日期选择器、下拉、滚动条)仍是刺眼的白底。
- css/style.css::root 保留默认主题那一份作兜底;导航底色改走 --nav-bg,
  滚动条改走变量,按钮光晕改走 --primary-rgb。
- lib/db.php / lib/auth.php:admins 增列 theme(幂等 ALTER,老库自动补,空值 = 默认主题);
  setAdminTheme() 校验 key、写库、同步 session。
- settings.php:主题卡片区。每张预览卡自己套一层 data-theme,用的就是那套主题真实的
  变量,因此「预览所见」与「换上去之后」必然一致,不必再抄一份色值;另有一小段 JS
  在点选时改 <html data-theme> 做即时预览(没脚本时选中后提交同样生效)。
- 14 个页面:<html> 上输出 data-theme,并在 style.css 之后引入 themes.css;
  内联样式里写死的橙色 rgba(230,126,34,…) 全部改走 rgba(var(--primary-rgb),…);
  影院/观影人色板选中态的描边由 #333 改为 var(--text-primary)(暗色主题下才看得见)。
- annual_report.php:报告本身仍是深色「海报」版式,但点缀色跟随主题;
  Chart.js 画在 canvas 上读不到 CSS 变量,改为运行时从 <html> 的计算样式取色。

顺手修的两个坑:
1. 注释里**不能**出现 PHP 起止标签。PHP 的结束标签在 // 注释里同样会关闭 PHP 模式,
   之后若再出现短标签就会被真的执行 —— lib/theme.php 的用法说明里写了完整标签示例,
   导致该文件被 require 时直接 Fatal error、整站白屏,而 php -l 查不出来(只看语法)。
   现在部署脚本里加了一道静态断言专门挡这个。
2. themesCssLink() 的返回值末尾补一个换行:PHP 会吃掉结束标签后面那一个换行,
   不补的话渲染结果里这一行会和下一行黏成一行。

回归:theme_e2e 111/111(含老库自动补列、脏值回落、5 个选项渲染、切换后落库+落 session+
立刻生效、跨账号隔离、非法 key 与注入被拒、themes.css 完整性、:root 与 ticket 一致、
无残留写死主色、无悬空 CSS 变量);reviews_e2e 84/84、fetch_movie 76/76、storage 196/196。
A/B 48 个响应:把「新增 head 行 / data-theme 属性 / 橙色→变量」三类等价替换归一化、
并把设置页新增区块整块删掉之后,**其余部分逐字节一致,零残留差异**。
线上 PHP 7.4.30 / 8.1.7 双版本冒烟全通过(迁移、5 套主题切换、跨账号隔离均验证,
写测试后已把 admin #1 的主题原样还原、其它账号一字未动)。
2026-09-22 20:33:47 +08:00

1569 lines
64 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
// ============================================
// 文件: detail.php (含观影人头像)
// ============================================
require_once 'config.php';
checkLogin();
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($id <= 0) {
header('Location: index.php');
exit;
}
$movie = getMovie($id);
if (!$movie) {
header('Location: index.php');
exit;
}
$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 = '';
$rvFormFeelings = []; // 评价表单里已勾选的观后感受
$rvCommentErrId = 0; // 哪一条评价下的「评论」表单报错
$rvReplyErrId = 0; // 哪一条评论下的「回复」表单报错
$rvCommentErr = ''; // 上面两种表单共用的错误文案
// 当前账号已有的评价(有则表单变成「修改」)
$myReview = getMyReview($id, $adminId);
if ($myReview) {
$rvFormRating = (int)$myReview['rating'];
$rvFormContent = $myReview['content'];
$rvFormFeelings = $myReview['feelings'];
}
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'] ?? ''));
$feelings = normalizeFeelings($_POST['feelings'] ?? []);
$rvFormRating = $rating;
$rvFormContent = $content;
// 出错重渲染时把用户勾过的感受带回去,不用重新勾一遍
$rvFormFeelings = $feelings;
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, $feelings)) {
$_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);
$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 === '') {
$rvCommentErr = $isReply ? '回复内容不能为空' : '评论内容不能为空';
} elseif (reviewTextLength($content) > REVIEW_COMMENT_MAX) {
$rvCommentErr = ($isReply ? '回复' : '评论') . '内容太长了(最多 ' . REVIEW_COMMENT_MAX . ' 字)';
} elseif (!$review || (int)$review['movie_id'] !== $id) {
$rvCommentErr = '这条评价不存在';
} else {
$newId = addReviewComment($reviewId, $adminId, $content, $feelings, $parentId);
if ($newId) {
$_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;
}
} 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();
// 评论 / 回复的错误提示默认贴在它来自的那个表单上。到底贴上了没有,由渲染过程
// 自己记录(渲染到那个表单时把 $rvCommentErrShown 置为 true)——这样不必事先遍历
// 判断,也不会漏掉「回复表单」这种情况。若到最后都没贴上(那个表单不在页面上,
// 比如被改过的 review_id / comment_id),就退化成区块顶部的提示,不静默吞掉。
$rvCommentErrShown = false;
// 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 弹到新窗口,没有关闭按钮、体验很差。
// 现在把所有图片收进这一个序列:点任意一张都在当前页弹出查看,
// 可以左右翻着看票根、合影等,Esc / 点空白处关闭。
// 这里一次性组装,英雄区海报与下方图库共用同一套下标。
$gallery = [];
$heroIndex = -1; // 海报在序列里的下标(没有真实海报时不点亮英雄区)
$ticketIndex = -1;
$photoIndexes = [];
$posterUrl = posterUrl($movie['poster_path']);
$hasRealPoster = ($posterUrl !== '' && strpos($posterUrl, 'placeholder') === false);
if ($hasRealPoster) {
$gallery[] = [
'url' => $posterUrl,
'label' => '🖼️ 海报',
'caption' => '电影海报 · ' . $movie['title'],
];
$heroIndex = 0;
}
$hasTicket = storageExists($movie['ticket_path'] ?? '');
if ($hasTicket) {
$gallery[] = [
'url' => storageUrl($movie['ticket_path']),
'label' => '🎫 电影票',
'caption' => '电影票根 · ' . $movie['title'],
];
$ticketIndex = count($gallery) - 1;
}
// 观影照片:旧字段 photo_path(历史单张)+ movie_photos(多张)
$all_photos = [];
if (storageExists($movie['photo_path'] ?? '')) {
$all_photos[] = ['path' => $movie['photo_path'], 'label' => '📸 旧照片'];
}
foreach ($movie['photos'] as $p) {
if (storageExists($p['photo_path'] ?? '')) {
$all_photos[] = ['path' => $p['photo_path'], 'label' => '📸 观影照片'];
}
}
foreach ($all_photos as $photo) {
$gallery[] = [
'url' => storageUrl($photo['path']),
'label' => $photo['label'],
'caption' => $photo['label'] . ' · ' . $movie['title'],
];
$photoIndexes[] = count($gallery) - 1;
}
$hasGallery = !empty($gallery);
?>
<!DOCTYPE html>
<html lang="zh-CN"<?= themeAttr() ?>>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= h($movie['title']) ?> - 电影日志</title>
<link rel="stylesheet" href="css/style.css">
<?= themesCssLink() ?>
<style>
.detail-container {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
.detail-back {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
text-decoration: none;
font-size: 15px;
margin-bottom: 24px;
transition: color 0.2s;
}
.detail-back:hover {
color: var(--primary);
}
.detail-hero {
display: grid;
grid-template-columns: 300px 1fr;
gap: 32px;
background: var(--card-bg);
border-radius: 16px;
padding: 28px;
box-shadow: var(--shadow);
margin-bottom: 24px;
}
.detail-poster {
border-radius: 12px;
overflow: hidden;
background: var(--bg-secondary);
aspect-ratio: 2/3;
display: flex;
align-items: center;
justify-content: center;
}
.detail-poster img {
width: 100%;
height: 100%;
object-fit: cover;
}
.detail-poster .placeholder-poster {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #2d2d44, #1a1a2e);
color: rgba(255,255,255,0.6);
font-size: 48px;
}
.detail-poster .placeholder-poster .placeholder-title {
font-size: 16px;
margin-top: 8px;
text-align: center;
padding: 0 12px;
}
.detail-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-info h1 {
font-size: 28px;
font-weight: 700;
margin: 0 0 4px;
color: var(--text-primary);
}
.detail-info .meta-row {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 14px;
color: var(--text-secondary);
}
.detail-info .meta-row span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.detail-info .info-text {
color: var(--text-secondary);
line-height: 1.7;
margin: 8px 0 0;
font-size: 15px;
white-space: pre-wrap;
}
.detail-section {
background: var(--card-bg);
border-radius: 16px;
padding: 24px 28px;
box-shadow: var(--shadow);
margin-bottom: 20px;
}
.detail-section h3 {
font-size: 18px;
font-weight: 600;
margin: 0 0 16px;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 10px;
}
.detail-section h3 .badge {
font-size: 13px;
font-weight: 400;
background: var(--bg-secondary);
padding: 2px 12px;
border-radius: 20px;
color: var(--text-secondary);
}
.viewers-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.viewer-tag {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bg-secondary);
padding: 4px 12px 4px 6px;
border-radius: 20px;
font-size: 14px;
color: var(--text-primary);
}
.viewer-tag img {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
}
.cinema-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 24px;
font-size: 15px;
}
.cinema-info .label {
color: var(--text-secondary);
}
.cinema-info .value {
color: var(--text-primary);
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.gallery-item {
border-radius: 10px;
overflow: hidden;
background: var(--bg-secondary);
aspect-ratio: 4/3;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s;
position: relative;
}
.gallery-item:hover {
transform: scale(1.02);
}
.gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.gallery-item .label-badge {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0,0,0,0.7);
color: #fff;
font-size: 11px;
padding: 2px 12px;
border-radius: 12px;
backdrop-filter: blur(4px);
}
.detail-actions {
display: flex;
gap: 12px;
margin-top: 8px;
flex-wrap: wrap;
}
.detail-actions .btn {
padding: 10px 24px;
}
.btn-danger {
background: #e74c3c;
color: #fff;
}
.btn-danger:hover {
background: #c0392b;
}
.remark-text {
background: var(--bg-secondary);
padding: 12px 16px;
border-radius: 8px;
color: var(--text-primary);
border-left: 4px solid var(--primary);
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-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(var(--primary-rgb), 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(var(--primary-rgb), 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-item > .rv-feelings {
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(var(--primary-rgb), 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(var(--primary-rgb), 0.10);
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(var(--primary-rgb), 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(var(--primary-rgb), 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-actions,
.rv-item > .rv-feelings {
margin-left: 0;
}
/* 手机上回复再收一点缩进,别把内容挤窄了 */
.rv-comment-reply {
margin-left: 10px;
}
}
/* ---------- 图片灯箱(替代原来的 window.open 弹窗) ----------
z-index 要压过导航栏(导航栏是 100),这里用 300。 */
.lb-openable {
cursor: zoom-in;
}
.lb-openable:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.lightbox {
position: fixed;
inset: 0;
z-index: 300;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.88);
backdrop-filter: blur(4px);
/* 打开时不给滚动条留闪烁空间 */
overscroll-behavior: contain;
}
.lightbox.open {
display: flex;
}
.lb-stage {
display: flex;
align-items: center;
justify-content: center;
max-width: 92vw;
max-height: 82vh;
}
.lb-img {
max-width: 92vw;
max-height: 78vh;
width: auto;
height: auto;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
background: transparent;
user-select: none;
-webkit-user-drag: none;
}
.lb-btn {
position: absolute;
border: none;
cursor: pointer;
color: #fff;
background: rgba(255, 255, 255, 0.12);
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease;
font-family: inherit;
}
.lb-btn:hover {
background: rgba(255, 255, 255, 0.26);
}
.lb-btn:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* 只有一张图时 JS 会加 hidden;但作者样式 display:flex 优先级高于
UA 的 [hidden]{display:none},必须显式写这条才能真正隐藏箭头。 */
.lb-btn[hidden] {
display: none;
}
.lb-close {
top: 14px;
right: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
font-size: 18px;
line-height: 1;
}
.lb-nav {
top: 50%;
transform: translateY(-50%);
width: 48px;
height: 48px;
border-radius: 50%;
font-size: 28px;
line-height: 1;
padding-bottom: 4px; /* 让箭头符号视觉上居中 */
}
.lb-prev { left: 14px; }
.lb-next { right: 14px; }
.lb-bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 14px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #fff;
font-size: 14px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0));
}
.lb-caption {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb-counter {
flex: 0 0 auto;
opacity: 0.75;
font-variant-numeric: tabular-nums;
}
.lb-open { overflow: hidden; } /* 给 <body> 加,弹出时锁背景滚动 */
@media (max-width: 768px) {
.detail-hero {
grid-template-columns: 1fr;
padding: 20px;
}
.detail-poster {
max-width: 240px;
margin: 0 auto;
}
.cinema-info {
grid-template-columns: 1fr;
}
.gallery-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
}
@media (max-width: 480px) {
.detail-section {
padding: 16px 18px;
}
.detail-hero {
padding: 16px;
}
.detail-info h1 {
font-size: 22px;
}
/* 手机上手指够得到:箭头缩小位置也内收一点,给图片留出更多宽度 */
.lb-nav {
width: 40px;
height: 40px;
font-size: 24px;
}
.lb-prev { left: 6px; }
.lb-next { right: 6px; }
.lb-img {
max-width: 88vw;
max-height: 70vh;
}
.lb-bar {
padding: 10px 14px;
font-size: 13px;
}
}
</style>
</head>
<body>
<?php include 'nav.php'; ?>
<main class="main-content">
<div class="detail-container">
<a href="index.php" class="detail-back">← 返回列表</a>
<!-- 主信息 -->
<div class="detail-hero">
<div class="detail-poster<?= $hasRealPoster ? ' lb-openable' : '' ?>"
<?php if ($hasRealPoster): ?>
data-lb-index="0" role="button" tabindex="0"
aria-label="放大查看《<?= h($movie['title']) ?>》的电影海报"
title="点击查看大图"
<?php endif; ?>>
<?php if (!$hasRealPoster): ?>
<div class="placeholder-poster">
<span>🎬</span>
<span class="placeholder-title"><?= h($movie['title']) ?></span>
</div>
<?php else: ?>
<img src="<?= $posterUrl ?>" alt="<?= h($movie['title']) ?>">
<?php endif; ?>
</div>
<div class="detail-info">
<h1><?= h($movie['title']) ?></h1>
<div class="meta-row">
<span>📅 <?= $movie['watch_date'] ? date('Y年m月d日', strtotime($movie['watch_date'])) : '日期待补' ?></span>
<?php if ($movie['cinema_name']): ?>
<span>🏛️ <?= h($movie['cinema_name']) ?></span>
<?php endif; ?>
<span>🎫 观影人: <?= count($movie['viewers']) ?> 人</span>
</div>
<?php if ($movie['info']): ?>
<div class="info-text"><?= h($movie['info']) ?></div>
<?php endif; ?>
<?php if ($movie['remark']): ?>
<div class="remark-text">📝 <?= h($movie['remark']) ?></div>
<?php endif; ?>
<div class="detail-actions">
<a href="edit.php?id=<?= $movie['id'] ?>" class="btn btn-primary">✏️ 编辑</a>
<a href="delete.php?id=<?= $movie['id'] ?>" class="btn btn-danger" onclick="return confirm('确定要删除《<?= h($movie['title']) ?>》吗?此操作不可恢复!')">🗑️ 删除</a>
</div>
</div>
</div>
<!-- 一起看的人(带头像) -->
<div class="detail-section">
<h3>👥 一起看的人 <span class="badge"><?= count($movie['viewers']) ?> 人</span></h3>
<?php if (count($movie['viewers']) > 0): ?>
<div class="viewers-list">
<?php foreach ($movie['viewers'] as $viewer): ?>
<span class="viewer-tag" style="color: <?= h($viewer['person_color'] ?? '#888888') ?>;">
<img src="<?= getAvatarUrl($viewer['person_avatar'] ?? null, $viewer['name']) ?>" alt="<?= h($viewer['name']) ?>">
<?= h($viewer['name']) ?>
</span>
<?php endforeach; ?>
</div>
<?php else: ?>
<p style="color: var(--text-secondary); font-size: 14px;">暂无记录</p>
<?php endif; ?>
</div>
<!-- 电影院信息 -->
<div class="detail-section">
<h3>🏛️ 电影院信息</h3>
<?php if ($movie['cinema_name']): ?>
<div class="cinema-info">
<span class="label">所在地方</span>
<span class="value"><?= h($movie['place'] ?? '-') ?></span>
<span class="label">影院名称</span>
<span class="value" style="color: <?= h($movie['cinema_color'] ?? '#888888') ?>;">
<?= h($movie['cinema_name']) ?>
</span>
<span class="label">详细地址</span>
<span class="value"><?= h($movie['cinema_address'] ?? '-') ?></span>
</div>
<?php else: ?>
<p style="color: var(--text-secondary); font-size: 14px;">未记录电影院信息</p>
<?php endif; ?>
</div>
<!-- 图片展示(票根 + 多张观影照片)
注意:$gallery / $hasTicket / $all_photos 已在页面顶部统一组装,
这里只负责渲染,避免同一份数据在两处各算一遍。 -->
<?php if ($hasTicket || count($all_photos) > 0): ?>
<div class="detail-section">
<h3>🖼️ 影像记录 <span class="badge"><?= count($all_photos) + ($hasTicket ? 1 : 0) ?> 张</span></h3>
<div class="gallery-grid">
<?php if ($hasTicket): ?>
<div class="gallery-item" data-lb-index="<?= $ticketIndex ?>"
role="button" tabindex="0" aria-label="放大查看电影票根" title="点击查看大图">
<img src="<?= $gallery[$ticketIndex]['url'] ?>" alt="电影票">
<span class="label-badge">🎫 电影票</span>
</div>
<?php endif; ?>
<?php foreach ($all_photos as $i => $photo): ?>
<?php $idx = $photoIndexes[$i]; ?>
<div class="gallery-item" data-lb-index="<?= $idx ?>"
role="button" tabindex="0" aria-label="放大查看观影照片" title="点击查看大图">
<img src="<?= $gallery[$idx]['url'] ?>" alt="观影照片">
<span class="label-badge"><?= $photo['label'] ?></span>
</div>
<?php endforeach; ?>
</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; ?>
<!-- 没评价过:只显示一个「写评价」按钮,点开才出现输入框;评价完这个按钮就没了 -->
<?php if (!$myReview): ?>
<?= $rvFormHtml('📝 写评价', $rvError !== '') ?>
<?php endif; ?>
<?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>
<?= $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 $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">
<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>
<?= $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; ?>
<!-- 操作行:默认都只是按钮,点开才出现输入框;
已评价过的用户,在「我这条评价」下面多一个「修改」 -->
<div class="rv-actions">
<?= $rvCommentBox($rv['id'], 0, '💬 评论', $rvCommentErrId === (int)$rv['id']) ?>
<?php if ($rvMine): ?>
<?= $rvFormHtml('✏️ 修改我的评价', $rvError !== '') ?>
<?php endif; ?>
</div>
</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>
<?php if ($hasGallery): ?>
<!-- 图片灯箱:点任意一张图在当前页弹出,可左右翻看 / Esc 关闭 -->
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true"
aria-hidden="true" aria-label="图片查看器">
<button type="button" class="lb-btn lb-close" id="lbClose" aria-label="关闭">✕</button>
<button type="button" class="lb-btn lb-nav lb-prev" id="lbPrev" aria-label="上一张">‹</button>
<button type="button" class="lb-btn lb-nav lb-next" id="lbNext" aria-label="下一张">›</button>
<div class="lb-stage" id="lbStage">
<img class="lb-img" id="lbImg" alt="">
</div>
<div class="lb-bar">
<span class="lb-caption" id="lbCaption"></span>
<span class="lb-counter" id="lbCounter"></span>
</div>
</div>
<script>
(function () {
var GALLERY = <?= json_encode(
array_values($gallery),
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
) ?>;
var box = document.getElementById('lightbox');
var stageEl = document.getElementById('lbStage');
var imgEl = document.getElementById('lbImg');
var capEl = document.getElementById('lbCaption');
var cntEl = document.getElementById('lbCounter');
var closeEl = document.getElementById('lbClose');
var prevEl = document.getElementById('lbPrev');
var nextEl = document.getElementById('lbNext');
if (!box || !GALLERY.length) return;
var idx = 0;
var opener = null; // 记住是从哪张图点开的,关闭后焦点还回去
function render() {
var item = GALLERY[idx];
// img 元素在整段浏览过程中是复用的,而 main.js 的破图兜底会给
// <img> 打上 data-fallback-applied 标记后就不再处理;这里换图时清掉,
// 保证每张新图失败时都能正常退回到占位图。
// 注意:dataset.fallbackApplied 对应的属性名是 data-fallback-applied。
// 这里用 removeAttribute 而不是 delete dataset.x —— 前者在所有浏览器都确定生效。
imgEl.removeAttribute('data-fallback-applied');
imgEl.src = item.url;
imgEl.alt = item.caption || item.label;
capEl.textContent = item.caption || item.label;
cntEl.textContent = (idx + 1) + ' / ' + GALLERY.length;
// 只有一张图时不显示左右箭头
var multi = GALLERY.length > 1;
prevEl.hidden = !multi;
nextEl.hidden = !multi;
// 预取相邻两张,翻页时不白屏
[idx - 1, idx + 1].forEach(function (i) {
var j = (i + GALLERY.length) % GALLERY.length;
if (j !== idx) { var pre = new Image(); pre.src = GALLERY[j].url; }
});
}
function open(i, trigger) {
idx = Math.max(0, Math.min(GALLERY.length - 1, i));
opener = trigger || null;
box.classList.add('open');
box.setAttribute('aria-hidden', 'false');
document.body.classList.add('lb-open'); // 锁背景滚动
render();
closeEl.focus();
}
function close() {
box.classList.remove('open');
box.setAttribute('aria-hidden', 'true');
document.body.classList.remove('lb-open');
if (opener && opener.focus) opener.focus();
opener = null;
}
function step(d) {
idx = (idx + d + GALLERY.length) % GALLERY.length; // 循环翻看
render();
}
// 绑定所有触发点:英雄区海报 + 图库格子(都用 data-lb-index 标明下标)
Array.prototype.forEach.call(document.querySelectorAll('[data-lb-index]'), function (el) {
var i = parseInt(el.getAttribute('data-lb-index'), 10);
if (isNaN(i)) return;
el.addEventListener('click', function (e) { e.preventDefault(); open(i, el); });
el.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(i, el); }
});
});
closeEl.addEventListener('click', close);
prevEl.addEventListener('click', function (e) { e.stopPropagation(); step(-1); });
nextEl.addEventListener('click', function (e) { e.stopPropagation(); step(1); });
// 点画面空白处关闭;点在图片或按钮上不关
box.addEventListener('click', function (e) {
if (e.target === box || e.target === stageEl) close();
});
imgEl.addEventListener('click', function (e) { e.stopPropagation(); });
document.addEventListener('keydown', function (e) {
if (!box.classList.contains('open')) return;
if (e.key === 'Escape') { close(); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
else if (e.key === 'ArrowRight'){ e.preventDefault(); step(1); }
});
// 移动端左右滑动翻页
var tx = 0, ty = 0;
stageEl.addEventListener('touchstart', function (e) {
tx = e.changedTouches[0].clientX;
ty = e.changedTouches[0].clientY;
}, { passive: true });
stageEl.addEventListener('touchend', function (e) {
var dx = e.changedTouches[0].clientX - tx;
var dy = e.changedTouches[0].clientY - ty;
if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) step(dx < 0 ? 1 : -1);
}, { passive: true });
})();
</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>