v8:观影落地(MovieLog 整体迁入统一事件原语)+ 三处 UI 撤除

- Schema v8:events / movie_details / event_attendees / event_photos 四张表,
  uk_event_source(family_id, source_ref) 作迁移幂等锚
- Movie 模型:时间线/详情/增删改、同行人整组替换、照片逐张管理、感受英→中映射
- ImageSlot 新增 poster(2:3) / ticket(free) / photo(free) 三槽位;
  free 槽位不裁剪、整图等比缩进框(只缩不放大)
- OssDriver::copyObject:同桶 CopyObject(手写 V1 签名,x-oss-* 头按字典序进签名串)
- 路由:movies 时间线/详情/新增/编辑/删除/补照片/删照片;同行人按家庭过滤;
  访客只读(requireWrite)
- UI 撤除:首页「我的一句话」、家谱页「补充我的亲属关系 / 看整个名册」两个按钮;
  顶栏加「观影」入口
- 修潜伏 bug:ImageSlot::parseRatio 的正则用 / 作分隔符,字符类里的 / 把模式
  提前截断(Unknown modifier ']')—— ratio() 只在上传 POST 里被调到,一直没被观察到
- 线上数据迁移(临时 token 门禁页,用完即删):MovieLog 219 条观影 → events,
  14 位观影人新增进名册(带 MovieLog 头像),431 个 OSS 对象同桶拷贝
  (movielog/uploads/<dir>/x → family/<dir>/x),review/feelings/影院三段/照片全量搬
This commit is contained in:
2026-09-30 20:22:46 +08:00
parent 809443d429
commit ff353b1eee
13 changed files with 1112 additions and 43 deletions
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* 观影详情页。
*
* 海报 / 票根 / 照片一律走 media_url() 代理(全站唯一出口,见 helpers.php);
* 感受标签是迁移时已翻成中文的 JSON 数组,这里只展示不加工。
* 访客(只读)看不到编辑/删除/传照片那些表单 —— 与名册同一口径。
*/
$canWrite = \App\Auth::canWrite();
$feel = [];
if (!empty($m['feelings'])) {
$feel = json_decode((string)$m['feelings'], true);
if (!is_array($feel)) {
$feel = [];
}
}
$hasPoster = !empty($m['poster_key']);
$hasTicket = !empty($m['ticket_key']);
?>
<div class="page-head">
<h1><?= e($m['title']) ?></h1>
<span class="sub"><?= e((string)($m['starts_at'] ?? '日期待补')) ?></span>
<div class="actions">
<?php if ($canWrite): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies/edit', ['id' => (int)$m['id']])) ?>">编辑</a>
<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-danger btn-sm">删除</button>
</form>
<?php endif; ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies')) ?>">返回</a>
</div>
</div>
<div class="movie-detail">
<div class="m-poster-col">
<?php if ($hasPoster): ?>
<a href="<?= e(media_url($m['poster_key'])) ?>" target="_blank" rel="noopener">
<img class="m-poster-lg" src="<?= e(media_url($m['poster_key'])) ?>" alt="海报">
</a>
<?php else: ?>
<span class="m-poster-lg m-poster-empty" aria-hidden="true">影</span>
<?php endif; ?>
<?php if ($hasTicket): ?>
<a href="<?= e(media_url($m['ticket_key'])) ?>" target="_blank" rel="noopener">
<img class="m-ticket" src="<?= e(media_url($m['ticket_key'])) ?>" alt="票根" loading="lazy">
</a>
<?php endif; ?>
</div>
<div class="card card-pad m-info">
<?php if ($m['rating'] !== null || $feel): ?>
<div class="m-score">
<?php if ($m['rating'] !== null): ?>
<span class="rating rating-lg"><?= e(rtrim(rtrim(number_format((float)$m['rating'], 1, '.', ''), '0'), '.')) ?></span>
<?php endif; ?>
<?php foreach ($feel as $f): ?>
<span class="badge badge-feel"><?= e($f) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<dl class="kv">
<div class="kv-row"><dt>观影日期</dt><dd><?= e((string)($m['starts_at'] ?? '日期待补')) ?></dd></div>
<?php if (!empty($m['cinema_place']) || !empty($m['cinema_name']) || !empty($m['cinema_address'])): ?>
<div class="kv-row"><dt>影院</dt><dd>
<?= e(trim(implode(' ', array_filter([
(string)($m['cinema_place'] ?? ''),
(string)($m['cinema_name'] ?? ''),
(string)($m['cinema_address'] ?? ''),
], static fn($s) => $s !== '')))) ?>
</dd></div>
<?php endif; ?>
<?php if (!empty($m['location'])): ?>
<div class="kv-row"><dt>地点</dt><dd><?= e($m['location']) ?></dd></div>
<?php endif; ?>
<div class="kv-row"><dt>同行的人</dt><dd>
<?php if ($m['attendees']): ?>
<?php foreach ($m['attendees'] as $i => $a): ?><?= $i > 0 ? '、' : '' ?><a href="<?= e(url('people/show', ['id' => (int)$a['id']])) ?>"><?= e($a['name']) ?></a><?php endforeach; ?>
<?php else: ?>一个人看的<?php endif; ?>
</dd></div>
</dl>
<?php if (!empty($m['info'])): ?>
<div class="m-block"><h3>简介</h3><p><?= nl2br(e($m['info'])) ?></p></div>
<?php endif; ?>
<?php if (!empty($m['review'])): ?>
<div class="m-block"><h3>观后感</h3><p><?= nl2br(e($m['review'])) ?></p></div>
<?php endif; ?>
<?php if (!empty($m['note'])): ?>
<div class="m-block"><h3>备注</h3><p><?= nl2br(e($m['note'])) ?></p></div>
<?php endif; ?>
</div>
</div>
<?php if ($m['photos']): ?>
<section class="section">
<h2 class="section-title">现场照片 <span class="hint"><?= count($m['photos']) ?> 张</span></h2>
<div class="photo-grid">
<?php foreach ($m['photos'] as $ph): ?>
<div class="photo-cell">
<a href="<?= e(media_url($ph['key'])) ?>" target="_blank" rel="noopener">
<img src="<?= e(media_url($ph['key'])) ?>" alt="" loading="lazy">
</a>
<?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>
</section>
<?php endif; ?>
<?php if ($canWrite): ?>
<section class="section">
<form method="post" enctype="multipart/form-data" action="<?= e(url('movies/photo_add')) ?>" class="card card-pad photo-add">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<label for="photo">补一张现场照片</label>
<div class="photo-add-row">
<input class="input" id="photo" name="photo" type="file" accept="image/*" required>
<button type="submit" class="btn btn-ghost btn-sm">上传</button>
</div>
</form>
</section>
<?php endif; ?>