Files
FamilyBook/templates/movies/show.php
T
laoyang 835f2846f2 取消「票根」概念,并入观影照片(数据迁移 220 条)(#87)
用户:现在不是已经没有票根的概念了吗?全部都换成观影照片了。别管是票根
还是观影照片,都在一起。现在只分两类:一类是海报,一类是观影照片。
海报是来自豆瓣的,这个不用管它,主要就是观影照片,所有的都在观影照片里边。

事实核查:概念取消了,数据没取消。网页端 v35 只在**模板层**把它们画进
同一个网格,数据层仍是两条路:票根在 movie_details.ticket_key(单张槽位),
不在 event_photos 里 ⇒ 没有 id ⇒ 删不掉,端上得写"票根另算"特判。

线上数据:有票根 220 条,其中 209 条只有票根、没有任何照片;
event_photos 总数才 14 —— 也就是说「观影照片」里装的几乎全是票根。

① 数据迁移(已执行,220/220 成功,失败 0):
   逐条把 ticket_key 插进 event_photos(position 取比现有最小值再小 10,
   ⇒ 排第一,因为票根是"这场电影的凭证",用户认知里排最前),然后置 NULL。
   event_photos 14 → 234,旧票根残留 0。
   ⚠️ 幂等:靠"该 key 已在该 event 的 event_photos 里"判断,重复跑不插重复行;
   ⚠️ 不删存储对象:只搬引用,图片原地不动,失败也不丢图。

② 同一段逻辑写进 Schema VERSION 36→37,让新库/重装库走同一套,
   不靠"记得手工跑脚本"。对线上是空操作(已验证 234→234)。

③ 服务端停止读写票根字段(字段保留作历史审计,代码不再碰):
   - Movie::find 不再 SELECT ticket_key;
   - Movie::saveDetails UPSERT 去掉 ticket_key;
   - Movie::destroy 不再单独查/删票根对象(已并入 photos 循环);
   - index.php:上传槽位 [poster,ticket] → [poster],
     stage_ticket 不再认领,media_del 白名单只剩 poster;
   - form.php:删票根特殊格子 + mv-del-ticket 表单 + $mHasTicket;
   - show.php:删票根格子 + 灯箱 ticket 项 + $hasTicket。

验证:find(1,1)/find(1,224) 已不返回 ticket_key,photos 3/4 条票根在首位;
直接 include 模板无 Fatal,form.php 里 mv-del-ticket / stage_ticket 各 0 次;
6 个文件 md5 逐个核对一致、php -l 全过、已上线且升级到 37。
2026-11-06 11:25:47 +08:00

759 lines
40 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
/**
* 观影详情页(v9,展示方式照搬 MovieLog 的 detail.php)。
*
* 五大块:英雄区(左海报右信息)→ 一起看的人 → 电影院信息 → 观影照片(灯箱)→ 评价与评论。
* 海报/观影照片一律走 media_url() 代理;感受标签只存 key,emoji 与文案查 CATALOG。
* 访客(只读)看不到编辑/删除/传照片/评价表单 —— 与名册同一口径。
* 评价人是「登录账号背后的人」,与同行观影人无关(MovieLog 同款规则)。
*/
$canWrite = \App\Auth::canWrite();
$hasPoster = !empty($m['poster_key']);
/* 🔴 2026-11-06:`$hasTicket` 取消 —— 票根已并入 `event_photos`,
只分「海报 / 观影照片」两类。 */
// 豆瓣元数据(v10):整体 JSON 存 douban_meta,随用随解;解不开就当没有
$doubanMeta = [];
if (!empty($m['douban_meta'])) {
$decoded = json_decode((string)$m['douban_meta'], true);
if (is_array($decoded)) {
$doubanMeta = $decoded;
}
}
$watchTxt = \App\Movie::formatWatch($m['starts_at'], $m['ends_at'] ?? null);
$fmtNum = static function ($v): string {
if ($v === null) {
return '';
}
return rtrim(rtrim(number_format((float)$v, 1, '.', ''), '0'), '.');
};
/** 头像或首字占位(persons.avatar 是存储相对键) */
$avatarHtml = static function (?string $avatar, string $name, string $cls = '') {
if ($avatar !== null && $avatar !== '') {
return '<img class="rv-avatar ' . e($cls) . '" src="' . e(media_url($avatar)) . '" alt="">';
}
return '<span class="rv-avatar rv-avatar-text ' . e($cls) . '">' . e(mb_substr($name, 0, 1, 'UTF-8')) . '</span>';
};
/** 可勾选的感受 chips(原生 checkbox,外观在 .feel-inner 上) */
$feelPick = static function (array $checked = []) use ($feelCat) {
ob_start(); ?>
<span class="feel-chips">
<?php foreach ($feelCat as $k => $f): ?>
<label class="feel-chip feel-pick" title="<?= e($f['label']) ?>">
<input type="checkbox" name="feelings[]" value="<?= e($k) ?>"<?= in_array($k, $checked, true) ? ' checked' : '' ?>>
<span class="feel-inner"><span class="feel-emoji"><?= $f['emoji'] ?></span><span class="feel-label"><?= e($f['label']) ?></span></span>
</label>
<?php endforeach; ?>
</span>
<?php return ob_get_clean();
};
/** 只读的感受标签 */
$feelTags = static function (array $keys) use ($feelCat) {
if (!$keys) {
return '';
}
ob_start(); ?>
<span class="rv-feelings">
<?php foreach ($keys as $k): ?>
<?php if (!isset($feelCat[$k])) { continue; } $f = $feelCat[$k]; ?>
<span class="feel-chip is-static" title="观后感受:<?= e($f['label']) ?>">
<span class="feel-emoji"><?= $f['emoji'] ?></span><span class="feel-label"><?= e($f['label']) ?></span>
</span>
<?php endforeach; ?>
</span>
<?php return ob_get_clean();
};
/** 静态 10 星展示(v10):左 n 颗实心金黄,其余空心灰;n 越界按 0/10 兜底 */
$starsHtml = static function (int $n): string {
$n = max(0, min(10, $n));
$out = '<span class="stars-disp" aria-hidden="true">';
for ($i = 1; $i <= 10; $i++) {
$out .= '<span class="' . ($i <= $n ? 'd-on' : 'd-off') . '">' . ($i <= $n ? '★' : '☆') . '</span>';
}
return $out . '</span>';
};
/** 我的评价表单(打分 + 正文 + 感受),details 按需展开;写评价与修改共用这一份 */
$rvForm = static function (string $summary, bool $open) use ($m, $myReview, $feelPick, $rvError) {
ob_start(); ?>
<details class="rv-composer"<?= $open ? ' open' : '' ?>>
<summary class="rv-open-btn"><?= e($summary) ?></summary>
<form method="post" action="<?= e(url('movies/review_save')) ?>" class="rv-form" id="reviewForm">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<div class="rv-rating-row">
<span class="rv-rating-label">我的评分</span>
<?php /* v10:10 星制。DOM 倒序(10..1)+ CSS row-reverse,视觉 1..10 从左到右;
点亮由 JS 加 .lit(含悬停预览), radios 保持 name="rating" 原语义。 */ ?>
<span class="rv-scale rv-stars no-js" role="radiogroup" aria-label="评分,1 分到 10 分">
<?php $myRating = (int)($myReview['rating'] ?? 0); ?>
<?php for ($i = 10; $i >= 1; $i--): ?>
<label class="rv-star" data-v="<?= $i ?>" title="<?= $i ?> 分 · <?= e(\App\MovieReview::ratingWord($i)) ?>">
<input type="radio" name="rating" value="<?= $i ?>"<?= $myRating === $i ? ' checked' : '' ?>>
<span class="st-glyph st-off" aria-hidden="true">☆</span><span class="st-glyph st-on" aria-hidden="true">★</span>
</label>
<?php endfor; ?>
</span>
<span class="rv-word<?= $myRating > 0 ? ' is-set' : '' ?>" id="rvWord">
<?= $myRating > 0 ? $myRating . ' 分 · ' . e(\App\MovieReview::ratingWord($myRating)) : '未评分' ?>
</span>
</div>
<textarea name="content" class="rv-textarea" maxlength="<?= (int)\App\MovieReview::CONTENT_MAX ?>" required
placeholder="这部电影怎么样?剧情、表演、画面、音效……随便聊聊"><?= e((string)($myReview['content'] ?? '')) ?></textarea>
<div class="rv-feeling-pick">
<span class="rv-pick-label">我看完的感受(可多选)</span>
<?= $feelPick((array)($myReview['feelings'] ?? [])) ?>
</div>
<div class="rv-form-foot">
<span class="rv-hint">每个账号对每部电影只有一条评价,再次提交即为修改。</span>
<button type="submit" class="btn btn-sm"><?= $myReview ? '保存修改' : '发表评价' ?></button>
</div>
</form>
</details>
<?php return ob_get_clean();
};
/** 评论 / 回复表单(parent_id=0 是对评价发评论;>0 是回复某条评论) */
$commentBox = static function (int $reviewId, int $parentId, string $summary) use ($m, $feelPick) {
ob_start(); ?>
<details class="rv-composer rv-inline">
<summary class="rv-act-btn"><?= e($summary) ?></summary>
<form method="post" action="<?= e(url('movies/comment_add')) ?>" class="rv-form rv-form-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="review_id" value="<?= $reviewId ?>">
<input type="hidden" name="parent_id" value="<?= $parentId ?>">
<textarea name="content" class="rv-textarea rv-textarea-sm" maxlength="<?= (int)\App\MovieReview::COMMENT_MAX ?>" required
placeholder="<?= $parentId > 0 ? '回复这一条…' : '说说你的看法…' ?>"></textarea>
<div class="rv-feeling-pick">
<span class="rv-pick-label">观后感受(可多选)</span>
<?= $feelPick() ?>
</div>
<div class="rv-form-foot">
<button type="submit" class="btn btn-ghost btn-sm"><?= $parentId > 0 ? '发表回复' : '发表评论' ?></button>
</div>
</form>
</details>
<?php return ob_get_clean();
};
?>
<div class="page-head">
<?php /* 🔴 v40(用户 2026-10-10):「观影详情页面左上角的观影记录,应该和观影的
列表页面里面保持一致,观影详情页面的编辑,还有返回观影的按钮,应该和观影列表
页面里面的记一场电影保持一致,删除按钮就用红色背景颜色。」
⇒ ① 标题从 `<span class="sub">观影记录</span>`(没有 h1)改成与列表页**同款**
`<h1>观影记录</h1>`,片名降级到 sub —— 两页左上角看起来就是一件事;
② 「编辑」「返回观影」用**裸 `.btn`**(= 列表页「记一场电影」那个品牌实心款);
③ 「删除」用 `.btn-del`(**实心红底**,不是原来那支淡红描边)。 */ ?>
<h1>观影记录</h1>
<span class="sub"><?= e($m['title']) ?></span>
<div class="actions">
<?php if ($canWrite): ?>
<?php /* v40:「编辑」不再是跳页,而是**就地弹框**(与列表页「记一场电影」同一份
form.php 模板)。仍然保留 data-href —— JS 没跑时它是可直接访问的链接。
⚠️ 按钮类名是**裸 `.btn`**,不是 `.btn-edit`:用户这一条要的是
「和观影列表页面里面的记一场电影**保持一致**」(= 该模块品牌色实心),
"编辑用蓝色"那句是针对**影院管理**说的(那边另用 `.btn-edit`)。
实测列表页那个按钮是 `rgb(120,77,138)`(观影主题紫),这里是同一个值。 */ ?>
<button class="btn" type="button" id="mvEditBtn" aria-haspopup="dialog" aria-expanded="false"
data-href="<?= e(url('movies/edit', ['id' => (int)$m['id']])) ?>">编辑</button>
<form method="post" action="<?= e(url('movies/destroy')) ?>"
onsubmit="return confirm('删除这条观影记录?海报、观影照片也会一并删除,不可恢复。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<button type="submit" class="btn btn-del">删除</button>
</form>
<?php endif; ?>
<?php /* 设置入口放在 actions 最后 → 永远在最右侧(用户 2026-10-01 要求) */ ?>
<a class="btn" href="<?= e(url('movies')) ?>">返回观影</a>
<?php $setCurrent = 'show'; $setCanWrite = $canWrite; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<!-- 主信息:英雄区 -->
<div class="detail-hero">
<div class="detail-poster<?= $hasPoster ? ' lb-openable' : '' ?>"
<?php if ($hasPoster): ?>data-lb-index="0" role="button" tabindex="0" aria-label="放大查看海报" title="点击查看大图"<?php endif; ?>>
<?php if ($hasPoster): ?>
<img src="<?= e(media_url($m['poster_key'])) ?>" alt="海报">
<?php else: ?>
<span class="placeholder-poster">
<span class="ph-icon" aria-hidden="true">影</span>
<span class="placeholder-title"><?= e($m['title']) ?></span>
</span>
<?php endif; ?>
</div>
<div class="detail-info">
<h1><?= e($m['title']) ?></h1>
<div class="meta-row">
<span>📅 <?= e($watchTxt) ?></span>
<?php if (!empty($m['cinema_name'])): ?>
<span style="color: <?= e((string)($m['cinema_color'] ?: '#888888')) ?>;">🏛️ <?= e($m['cinema_name']) ?></span>
<?php endif; ?>
<span>👥 观影人 <?= count($m['attendees']) ?> 人</span>
</div>
<?php if ($doubanMeta): ?>
<div class="db-meta">
<?php if (!empty($doubanMeta['rating'])): ?>
<span class="db-rating" title="豆瓣评分">豆瓣 <b><?= e((string)$doubanMeta['rating']) ?></b></span>
<?php endif; ?>
<?php foreach ([
'年份' => (string)($doubanMeta['year'] ?? ''),
'类型' => (string)($doubanMeta['genre'] ?? ''),
'片长' => (string)($doubanMeta['runtime'] ?? ''),
'导演' => (string)($doubanMeta['director'] ?? ''),
'主演' => (string)($doubanMeta['actors'] ?? ''),
'制片国家/地区' => (string)($doubanMeta['country'] ?? ''),
'又名' => (string)($doubanMeta['alias'] ?? ''),
] as $dbLbl => $dbVal): ?>
<?php if ($dbVal !== ''): ?>
<span class="db-line"><span class="db-label"><?= $dbLbl ?></span><?= e($dbVal) ?></span>
<?php endif; ?>
<?php endforeach; ?>
<?php if (!empty($doubanMeta['douban_url'])): ?>
<a class="db-link" href="<?= e((string)$doubanMeta['douban_url']) ?>"
target="_blank" rel="noopener noreferrer">在豆瓣查看 ↗</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (!empty($m['info'])): ?>
<div class="info-text"><?= nl2br(e($m['info'])) ?></div>
<?php endif; ?>
<?php if (!empty($m['note'])): ?>
<div class="remark-text">📝 <?= nl2br(e($m['note'])) ?></div>
<?php endif; ?>
</div>
</div>
<!-- 一起看的人 -->
<div class="detail-section">
<h3>一起看的人 <span class="badge"><?= count($m['attendees']) ?> 人</span></h3>
<?php if ($m['attendees']): ?>
<div class="viewers-list">
<?php foreach ($m['attendees'] as $a): ?>
<?= person_chip($a, url('people/show', ['id' => (int)$a['id']])) ?>
<?php endforeach; ?>
</div>
<?php else: ?>
<p class="empty-hint">一个人看的</p>
<?php endif; ?>
</div>
<!-- 电影院信息 -->
<div class="detail-section">
<h3>电影院信息</h3>
<?php if (!empty($m['cinema_name'])): ?>
<div class="cinema-info">
<span class="label">所在地方</span>
<span class="value"><?= e((string)($m['cinema_place'] ?? '-')) ?></span>
<span class="label">影院名称</span>
<span class="value" style="color: <?= e((string)($m['cinema_color'] ?: '#888888')) ?>;"><?= e($m['cinema_name']) ?></span>
<span class="label">详细地址</span>
<span class="value"><?= e((string)($m['cinema_address'] ?? '-')) ?></span>
</div>
<?php else: ?>
<p class="empty-hint">未记录电影院信息</p>
<?php endif; ?>
</div>
<!-- 观影照片(v38:**只此一类**,票根已并入 event_photos) -->
<?php if ($m['photos']): ?>
<div class="detail-section">
<h3>观影照片 <span class="badge"><?= count($m['photos']) ?> 张</span></h3>
<div class="gallery-grid">
<?php /* 🔴 2026-11-06:**票根那个特殊格子删掉了**。用户要求只分两类
(海报 / 观影照片);旧 `movie_details.ticket_key` 里的票根
已经一次性迁进 `event_photos`(220 条,position 排最前),
现在它就是下面 foreach 里的普通一张 —— 同一个灯箱、同一个删除路由。
⚠️ 别再把它加回来,留着就等于承认还有第三类。
⚠️ `$lbIdx` 也因此从 1 起直接算,不再需要 `+ ($hasTicket ? 2 : 1)`。 */ ?>
<?php foreach ($m['photos'] as $i => $ph): ?>
<div class="gallery-item lb-openable" data-lb-index="<?= $i + 1 ?>" role="button" tabindex="0" aria-label="放大查看照片" title="点击查看大图">
<img src="<?= e(media_url((string)$ph['key'])) ?>" alt="" loading="lazy">
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('movies/photo_del')) ?>"
onsubmit="return confirm('删除这张照片?')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="photo_id" value="<?= (int)$ph['id'] ?>">
<button type="submit" class="photo-del" title="删除这张照片">×</button>
</form>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php /* 🔴 v37 用户 2026-10-09 第 1 条:「观影详情页面。观影图片下面有一个块,
里面写着一句话。要加照片请点上方「编辑」…这个块不要,直接把这个块删除。」
⇒ 整块删掉(v36 留的那句提示也一起去掉)。加照片统一走「编辑」按钮。 */ ?>
<!-- 评价与评论 -->
<div class="detail-section" id="reviews">
<h3>评价与评论
<span class="badge">
<?php if ($rvStats['count'] > 0): ?>
<?= (int)$rvStats['count'] ?> 条评价 · 均分 <?= e($fmtNum($rvStats['avg'])) ?>
<?php else: ?>
还没有评价
<?php endif; ?>
</span>
</h3>
<?php if ($canWrite && !$myReview): ?>
<?= $rvForm('写评价', $rvError !== '') ?>
<?php endif; ?>
<?php if (!$reviews): ?>
<p class="empty-hint">还没有评价,等你来评一个。</p>
<?php else: ?>
<div class="rv-list">
<?php foreach ($reviews as $rv): ?>
<?php
$rvMine = ((int)$rv['person_id'] === $meId);
$rvName = (string)($rv['person_name'] ?? '已注销');
$rvRate = (int)$rv['rating'];
?>
<div class="rv-item">
<div class="rv-head">
<?= $avatarHtml($rv['person_avatar'] ?? null, $rvName) ?>
<span class="rv-who">
<?php /* v11:这里原本还有个写着「我」的徽标(类名与这个区块的首行同名)——
.rv-who 是 flex column,徽标被 align-items:stretch 拉成整行宽,
在星星旁边显出一条丑长的品牌色色带(用户反馈「特别长的条」),已删。
「是不是我」由删除按钮的存在表达,不需要徽标。 */ ?>
<span class="rv-name"><?= e($rvName) ?></span>
<span class="rv-time"><?= e(substr((string)$rv['created_at'], 0, 16)) ?></span>
</span>
<span class="rv-score" title="<?= e(\App\MovieReview::ratingWord($rvRate)) ?>"><?= $starsHtml($rvRate) ?><span class="rv-score-max"><?= $rvRate ?>/10</span></span>
<?php if ($rvMine && $canWrite): ?>
<form method="post" action="<?= e(url('movies/review_del')) ?>" class="rv-del"
onsubmit="return confirm('删除我的这条评价?下面的评论也会一起删除,不可恢复。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="review_id" value="<?= (int)$rv['id'] ?>">
<button type="submit" class="rv-del-btn" title="删除我的评价">×</button>
</form>
<?php endif; ?>
</div>
<div class="rv-content"><?= nl2br(e($rv['content'])) ?></div>
<?= $feelTags($rv['feelings']) ?>
<?php if (!empty($rv['updated_at']) && $rv['updated_at'] !== $rv['created_at']): ?>
<div class="rv-updated">已编辑 <?= e(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['person_id'] === $meId);
$cName = (string)($c['person_name'] ?? '已注销');
?>
<div class="rv-thread">
<div class="rv-comment">
<?= $avatarHtml($c['person_avatar'] ?? null, $cName, 'rv-avatar-sm') ?>
<span class="rv-c-body">
<span class="rv-c-head">
<span class="rv-name"><?= e($cName) ?></span>
<span class="rv-time"><?= e(substr((string)$c['created_at'], 0, 16)) ?></span>
<?php if ($cMine && $canWrite): ?>
<form method="post" action="<?= e(url('movies/comment_del')) ?>" class="rv-del"
onsubmit="return confirm('删除我的这条评论?它下面的回复也会一起删除。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="comment_id" value="<?= (int)$c['id'] ?>">
<button type="submit" class="rv-del-btn" title="删除我的评论">×</button>
</form>
<?php endif; ?>
</span>
<?= $feelTags($c['feelings']) ?>
<span class="rv-c-text"><?= nl2br(e($c['content'])) ?></span>
<span class="rv-c-acts"><?= $canWrite ? $commentBox((int)$rv['id'], (int)$c['id'], '回复') : '' ?></span>
</span>
</div>
<?php foreach ($thread['replies'] as $rep): ?>
<?php
$rMine = ((int)$rep['person_id'] === $meId);
$rName = (string)($rep['person_name'] ?? '已注销');
?>
<div class="rv-comment rv-comment-reply">
<?= $avatarHtml($rep['person_avatar'] ?? null, $rName, 'rv-avatar-sm') ?>
<span class="rv-c-body">
<span class="rv-c-head">
<span class="rv-name"><?= e($rName) ?></span>
<span class="rv-time"><?= e(substr((string)$rep['created_at'], 0, 16)) ?></span>
<?php if ($rMine && $canWrite): ?>
<form method="post" action="<?= e(url('movies/comment_del')) ?>" class="rv-del"
onsubmit="return confirm('删除我的这条回复?')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<input type="hidden" name="comment_id" value="<?= (int)$rep['id'] ?>">
<button type="submit" class="rv-del-btn" title="删除我的回复">×</button>
</form>
<?php endif; ?>
</span>
<?php if (!empty($rep['reply_to_name'])): ?>
<span class="rv-at">回复 <span>@<?= e($rep['reply_to_name']) ?></span></span>
<?php endif; ?>
<?= $feelTags($rep['feelings']) ?>
<span class="rv-c-text"><?= nl2br(e($rep['content'])) ?></span>
<span class="rv-c-acts"><?= $canWrite ? $commentBox((int)$rv['id'], (int)$rep['id'], '回复') : '' ?></span>
</span>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($canWrite): ?>
<div class="rv-actions">
<?= $commentBox((int)$rv['id'], 0, '评论') ?>
<?php if ($rvMine): ?>
<?= $rvForm('修改我的评价', $rvError !== '') ?>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php if ($hasPoster || $m['photos']): ?>
<!-- 图片灯箱:点任意一张图在当前页弹出,可左右翻看 / Esc 关闭(MovieLog 同款) -->
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-hidden="true" aria-label="图片查看器">
<?php /* v24 第 3 条:预览层右上角恒有下载按钮(与全局附件预览一个规矩) */ ?>
<a class="lb-btn lb-dl" id="lbDl" href="#" download>下载</a>
<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 items = [];
<?php if ($hasPoster): ?>
items.push({url: <?= json_encode(media_url((string)$m['poster_key']), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, cap: <?= json_encode('海报 · ' . $m['title'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>});
<?php endif; ?>
<?php /* 🔴 2026-11-06:票根那条 items.push 删掉了 —— 它已在 `$m['photos']` 里 */ ?>
<?php foreach ($m['photos'] as $ph): ?>
items.push({url: <?= json_encode(media_url((string)$ph['key']), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, cap: <?= json_encode('观影照片 · ' . $m['title'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>});
<?php endforeach; ?>
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 dlEl = document.getElementById('lbDl');
var prevEl = document.getElementById('lbPrev');
var nextEl = document.getElementById('lbNext');
if (!box || !items.length) return;
var idx = 0, opener = null;
function render() {
imgEl.src = items[idx].url;
// 下载地址:与预览同一个键,只是带上 dl=1(服务端会改 Content-Disposition)
if (dlEl) {
dlEl.setAttribute('href', items[idx].url + (items[idx].url.indexOf('?') < 0 ? '?' : '&') + 'dl=1');
}
capEl.textContent = items[idx].cap;
cntEl.textContent = (idx + 1) + ' / ' + items.length;
var multi = items.length > 1;
prevEl.hidden = !multi;
nextEl.hidden = !multi;
}
function open(i, trigger) {
idx = Math.max(0, Math.min(items.length - 1, i));
opener = trigger || null;
box.classList.add('open');
box.setAttribute('aria-hidden', 'false');
render();
closeEl.focus();
}
function close() {
box.classList.remove('open');
box.setAttribute('aria-hidden', 'true');
if (opener && opener.focus) opener.focus();
opener = null;
}
function step(d) { idx = (idx + d + items.length) % items.length; render(); }
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; ?>
<?php if ($canWrite): ?>
<!-- 打分:星星点亮 + 悬停预览 + 实时提示「几分 · 怎么样」;radio 没 JS 也能选(:has 兜底点亮) -->
<script>
(function () {
var form = document.getElementById('reviewForm');
var word = document.getElementById('rvWord');
if (!form || !word) return;
var scale = form.querySelector('.rv-stars');
if (scale) scale.classList.remove('no-js');
var WORDS = <?= json_encode(\App\MovieReview::ratingWords(), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
function pickedV() {
var picked = form.querySelector('input[name="rating"]:checked');
return picked ? parseInt(picked.value, 10) : 0;
}
function paint(v) {
if (!scale) return;
Array.prototype.forEach.call(scale.querySelectorAll('.rv-star'), function (el) {
el.classList.toggle('lit', parseInt(el.getAttribute('data-v'), 10) <= v);
});
}
function sync() {
var v = pickedV();
if (!v) { word.textContent = '未评分'; word.classList.remove('is-set'); paint(0); return; }
word.textContent = v + ' 分 · ' + (WORDS[v - 1] || '');
word.classList.add('is-set');
paint(v);
}
Array.prototype.forEach.call(form.querySelectorAll('input[name="rating"]'), function (el) {
el.addEventListener('change', sync);
});
if (scale) {
scale.addEventListener('mouseover', function (e) {
var lab = e.target.closest ? e.target.closest('.rv-star') : null;
if (lab) paint(parseInt(lab.getAttribute('data-v'), 10));
});
scale.addEventListener('mouseleave', function () { paint(pickedV()); });
}
sync();
})();
</script>
<?php endif; ?>
<?php /* 🔴🔴 v40(用户 2026-10-10):「观影详情里面的编辑按钮应该也是弹出的,和新增是一样的,
编辑完以后,页面动态更新。」
⇒ 详情页内嵌**同一份** `form.php`(编辑态),点「编辑」就地弹出,
提交走 AJAX:服务端 302 到详情页 → 前端 fetch 跟到最终页 → 换掉正文容器。
⚠️ 三个开关(含义见 form.php 文件头):
· `$mvModalOnly = true` → 不输出 page-head / 设置菜单(详情页自己有一个);
· `$mvModalForce = true` → `$isEdit` 也要弹框壳(默认编辑是整页卡片);
· `$mvOpen = false` → **初始关闭**(用户点「编辑」才开)。
⚠️ 只在**能写**的账号上输出:只读账号点开也提交不了(服务端 requireWrite)。
⚠️ 弹框里那对起止是 antd RangePicker,且是**动态插入**的 ⇒ 打开时必须调
`window.fbBootAntd()` 让它挂上(见 app.js 的那个接口说明)。 */ ?>
<?php if ($canWrite): ?>
<?php /* ⚠️ 类名必须**全限定**(`\App\...`):模板在**全局命名空间**里执行,
写 `Person::` 会直接 `Class "Person" not found` —— 那是致命错误,
页面从这里**中断输出**(后面整块弹框都不见了,而且不报给用户)。
实测就是这么做出来的:探针量到 `#mvAddCard` 不存在。 */ ?>
<?php
$mvModalOnly = true;
$mvModalForce = true;
$mvOpen = false;
$errors = [];
$people = \App\Person::roster((int)\App\Auth::familyId(), (int)\App\Auth::personId(), 'member', '');
$cinemas = \App\Cinema::all((int)\App\Auth::familyId());
require __DIR__ . '/form.php';
unset($mvModalOnly, $mvModalForce, $mvOpen, $errors, $people, $cinemas);
?>
<script>
/* ---- 详情页「编辑」:弹框 + AJAX 保存 + 保存后动态更新 ----
用户原话(2026-10-10):「观影详情里面的编辑按钮应该也是弹出的,和新增是一样的,
编辑完以后,页面动态更新。」
🔴 关键设计:**事件委托**(监听全部挂在 `document` 上,用选择器判断来源)。
为什么不用"取到元素再 addEventListener":校验失败时服务端会把这整块弹框
重画回来(见下面第 2 步),旧元素连它身上的监听一起被丢掉 ——
重绑逻辑写一遍容易、写对很难(漏一个入口就是"第二次点编辑没反应")。
委托之后**一次绑定、永久有效**,替换多少次都不用管。
提交流程:
1. `fetch(form.action, {X-Requested-With})` 提交整个 FormData;
2. **校验失败** → 服务端回 200 + JSON `{ok:false, modal:'<重画后的弹框>'}`
⇒ 把弹框整块换掉(红字、回填值、附件面板全由模板自己产出,与整页路径**同源**);
3. **成功** → 服务端 302 到 `movies/show`;`fetch` 默认跟重定向,
`r.url` 就是详情页 ⇒ 再取一次 HTML、只换正文容器(保留滚动位置、不闪白)。
⚠️ 不用 `redirect:'manual'`:那样拿到的是 opaque 响应(既读不到 Location、
也读不到内容),只能猜 URL;跟过去更直接。
⚠️ 换完正文容器要**重跑新页面里的内联 JS**:`innerHTML` 换掉后,
灯箱 / 评分 / 评论那些页内脚本绑的事件全丢了。重跑它们最省事也最可靠
(那几段本来就有幂等判断,再跑一次不会重复绑定)。 */
(function () {
if (!window.fetch || !window.FormData) { return; } // 老浏览器:保持原生整页提交
/* 🔴🔴 幂等闸门(必须):保存成功后我们会把正文容器整块换掉,并**重跑**
新页面里那几段内联 JS(否则灯箱 / 评分的事件全丢)。
而这段脚本自己也在正文容器里 ⇒ **它会被再跑一遍**。
没有这道闸门就会挂上**第二套 document 监听**:
· 一次提交被处理两次 → 两个 `fetch` 并发;
· 第二个回来时第一个已经把旧弹框换掉了 ⇒ `old.parentNode` 是 null
⇒ 抛错 ⇒ 落进 `.catch` ⇒ 退回 `form.submit()`(整页跳)。
实测症状:**校验失败时反而整页跳走、红字完全看不到**(v40 探针连红 3 条)。
⇒ 用 window 上的标记挡住第二次绑定 —— 事件委托本来就是"一次绑定、永久有效",
重跑只需要换掉 DOM,不需要重绑。 */
if (window.__fbMovieEditBound) { return; }
window.__fbMovieEditBound = true;
function modal() { return document.getElementById('mvAddCard'); }
function setOpen(on) {
var m = modal();
if (!m) { return; }
m.hidden = !on;
var b = document.getElementById('mvEditBtn');
if (b) { b.setAttribute('aria-expanded', on ? 'true' : 'false'); }
if (on) {
/* 🔴 弹框是**动态插入**的:日期控件要在这一刻才升级。
两步都要 —— 先绑自建下拉(兜底),再挂 antd(正主)。 */
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
var t = document.getElementById('title');
if (t) { t.focus(); }
}
}
function antdPanelOpen() {
var d = document.querySelector('.ant-picker-dropdown');
return !!(d && !d.classList.contains('ant-picker-dropdown-hidden'));
}
function busy(on) {
var s = document.getElementById('mvAddSubmit');
if (s) { s.disabled = !!on; s.textContent = on ? '保存中…' : '保存'; }
}
/* ---- 打开 / 关闭(委托) ---- */
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
if (t.closest('#mvEditBtn')) { e.preventDefault(); setOpen(true); return; }
if (t.closest('#mvAddX') || t.closest('#mvAddCancel')) { setOpen(false); return; }
var m = modal();
if (m && t === m) { setOpen(false); } // 点遮罩关;点面板内部不关
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') { return; }
var m = modal();
if (!m || m.hidden) { return; }
if (antdPanelOpen()) { return; } // 让 antd 先收它自己的面板(v38 的坑)
setOpen(false);
});
/* ---- 保存:click 与 submit 两条路都汇到这里 ---- */
function save() {
var form = document.getElementById('mvForm');
if (!form) { return; }
busy(true);
fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
}).then(function (r) {
var ct = (r.headers.get('content-type') || '');
if (ct.indexOf('application/json') >= 0) {
/* ② 校验失败:整块换成服务端重画的那一份 */
return r.json().then(function (j) {
var old = modal();
if (!old || !j || !j.modal) { return; }
var tmp = document.createElement('div');
tmp.innerHTML = j.modal;
var fresh = tmp.querySelector('#mvAddCard');
if (!fresh) { return; }
old.parentNode.replaceChild(fresh, old);
setOpen(true); // 换完仍开着(用户正在看它)
});
}
/* ③ 成功:服务端 302 到详情页,`fetch` 已**跟过去** ⇒ `r` 本身就是最终页的响应,
直接用它的 body,**不要再 fetch 一次**。
🔴🔴 v40 实测教训:多请求那一次会把 **flash 提示吃掉** ——
`flash()` 是一次性的(`take_flash()` 读完即清),
第二次 GET 时 session 里已经空了
⇒ 用户看到"保存成功了却没有任何提示"(探针红在这条)。
⚠️ 同时也省掉一次整页渲染(详情页不便宜)。 */
return r.text()
.then(function (html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var freshMain = doc.querySelector('main');
var curMain = document.querySelector('main');
if (!freshMain || !curMain) { window.location.reload(); return; }
curMain.innerHTML = freshMain.innerHTML;
if (doc.title) { document.title = doc.title; }
/* 🔴 只重跑**页内内联**脚本(灯箱、评分、评价表单那几段)——
⚠️ **绝不重跑带 `src` 的外部脚本**(app.js / person_picker.js…):
那些是库,重复执行会二次绑定全局监听、重建控件,行为不可预测。
判据就用 `old2.src`:有 src 的一律跳过(**不写 `src=""` 的空脚本**,
回退到 `textContent` 那条分支,两者互斥)。 */
Array.prototype.forEach.call(doc.querySelectorAll('script'), function (old2) {
if (old2.type && old2.type !== 'text/javascript') { return; }
if (old2.src) { return; } // 外部库:跳过(见上)
if (!old2.textContent || !old2.textContent.trim()) { return; }
var s = document.createElement('script');
s.textContent = old2.textContent;
document.body.appendChild(s);
});
/* ⚠️ flash 提示("已保存。")**不用手动补** —— 它是正文容器的第一个
子元素(见 layout.php),上面整块 innerHTML 替换时已经带过来了。
补一次反而会变成两条。 */
});
}).catch(function () {
/* 网络/解析异常:退回原生提交,让服务端把结果整页给出(不静默吞) */
busy(false);
form.submit();
}).then(function () { busy(false); });
}
/* 🔴🔴 为什么两条路都要挂(v40 实测教训):
`#mvAddSubmit` 是 `<button form="mvForm" type="submit">`(在表单**外面**,
靠 `form=` 关联)。这种"外部提交按钮"在浏览器里的行为比预想的绕:
实测点它时**真的提交了**(请求头上 `X-Requested-With` 是空的、
`resource_type=document`),但挂在 `document` 上的 `submit` 监听
**没有收到事件** —— 结果是原生整页跳走,校验失败的红字根本看不到。
⇒ 既然"点击"一定发生,就以 **click** 为主路(`preventDefault` 掉原生提交),
`submit` 只留着兜**回车提交**(在表单里按 Enter)。
两条路都进 `save()`,且 click 已经 `preventDefault` 时不会再触发 submit ⇒ 不会双发。 */
document.addEventListener('click', function (ev) {
var t = ev.target;
if (!t || !t.closest || !t.closest('#mvAddSubmit')) { return; }
if (!window.fetch || !window.FormData) { return; } // 老浏览器:交回原生提交
ev.preventDefault();
save();
});
document.addEventListener('submit', function (ev) {
var form = ev.target;
if (!form || form.id !== 'mvForm') { return; }
if (!window.fetch || !window.FormData) { return; }
ev.preventDefault();
save();
});
})();
</script>
<?php endif; ?>