Files
MovieLog_Server/detail.php
T
laoyang 7d199d5b3a feat: 详情页图片改用本页灯箱,不再 window.open 弹新窗口
以前点图片会 window.open 弹一个新窗口,没有关闭按钮、来回切换很别扭。
现在把「海报 → 票根 → 观影照片」收进一个序列,点任一张在当前页弹出查看。

交互
- 左右箭头循环翻看,底部显示「3 / 8」进度与图片说明,✕ 关闭。
- 键盘 ← / → 翻页、Esc 关闭;点画面空白处关闭(点图片或按钮不关)。
- 移动端左右滑动切换;手机上箭头缩小并内收,给图片让出更多宽度。
- 弹出时给 <body> 加锁滚动;预取相邻两张,翻页不白屏。
- 只有一张图时不显示左右箭头;关闭后焦点回到原来点开的那张图。
- 英雄区海报也变成可点开(占序列首位);无真实海报时维持占位块不变。

实现
- 图片序列在页面顶部一次性组装,英雄区与图库共用同一套 data-lb-index 下标,
  不再两处各算一遍;渲染与判断都统一走 storageExists / storageUrl。
- GALLERY 通过 json_encode 输出,带 JSON_HEX_TAG / HEX_AMP / HEX_APOS / HEX_QUOT,
  避免豆瓣侧或文件名里的 </script> 截断脚本。
- 候选文本一律经 textContent / caption 写入,不拼 HTML。

两个实现坑(本批修掉)
- 作者样式 .lb-btn{display:flex} 优先级高于 UA 的 [hidden]{display:none},
  单图时箭头实际隐藏不掉 —— 必须显式写 .lb-btn[hidden]{display:none}。
- 灯箱复用同一个 <img>,而 main.js 的破图兜底会给 <img> 打上
  data-fallback-applied 后就不再处理;每次换图要 removeAttribute 重新武装,
  否则只要有一张失败过,后续图片就不再有占位兜底。

验证
- php -l 通过;内联 JS/CSS 用「模拟 PHP 渲染」方式校验通过。
- 新增 lightbox_test.js(jsdom,对真实渲染出的详情页跑):38 项全通过,
  覆盖打开/翻页/循环/键盘/Esc/点空白关闭/点图片不关/单图隐藏箭头/破图标记复位。
  这两个坑正是它抓出来的(delete dataset.x 在 jsdom 下不生效,故改用 removeAttribute)。
- fetch_movie 端到端 76 项、storage 回归 196 项全通过。
- A/B 47 个响应:只有详情页(及会重定向到详情页的提交响应)6 个有差异,其余 41 个不变。
- 线上双 PHP(7.4/8.1)用真实数据渲染通过:4 张图序列、URL 为本站代理地址、
  结构齐备、无 window.open。
  (注:以 movie 身份跑 CLI 时 db/.settings.key 不可读,云端配置会被判为未完成、
  所有图片都判成「不存在」;冒烟脚本改为向进程内缓存注入一份假配置绕过,
  不写库、不碰真实密钥。这只影响探针,不影响以 www 运行的站点。)
2026-09-22 08:59:12 +08:00

684 lines
25 KiB
PHP
Raw 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();
// ---------- 灯箱图片序列:海报 → 票根 → 观影照片 ----------
// 以前每张图都是用 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">
<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">
<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;
}
/* ---------- 图片灯箱(替代原来的 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; ?>
</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; ?>
<script src="js/main.js"></script>
</body>
</html>