v9: 观影对齐 MovieLog —— 瀑布流/影院管理/评价评论/起止时间

- events 起止时间精确到秒(starts_at DATE→DATETIME + ends_at),_datetime.php 复用
- 影院独立成表(cinemas,地方/名称/地址/主题色 + 50 色板),movie_details 归一为 cinema_id
- 评价模型 MovieLog 同款:movie_reviews 每账号每片一条(1-10 分+正文+感受 key),
  movie_review_comments 两级平铺评论(回复的回复规范化到顶层、跨评价回复拦截)
- 感受目录迁到 MovieReview::CATALOG(12 种,Emoji 11.0 老码位)
- 观影列表改瀑布流(CSS grid 海报墙),详情页五大块 + 灯箱 + 评价区
- 升级路径:v8 老数据自动归一(影院三段按 (family,place,name) 去重建行、
  评价回填、中文感受反查英文 key),回填全部幂等;老库补 cinema_id 列+索引+外键
- 修三 bug:建表顺序(cinemas 必须在 movie_details 前,否则全新安装 1215)、
  layout 导航用键名当路由(影院链接指向 r=cinemas)、
  _datetime.php 分钟循环变量 $m 覆盖宿主模板的观影记录(编辑页简介/备注回填被清空)
This commit is contained in:
2026-10-01 00:12:04 +08:00
parent ff353b1eee
commit 746f02581e
13 changed files with 2001 additions and 356 deletions
+11 -7
View File
@@ -37,21 +37,24 @@ $dtSec = $dtTs ? (int)date('s', $dtTs) : 0;
aria-label="日期">
<span class="dt-time">
<select class="dt-hour" name="<?= e($dtName) ?>_hour" aria-label="时(24 小时制)">
<?php for ($h = 0; $h < 24; $h++): $hv = sprintf('%02d', $h); ?>
<option value="<?= $hv ?>"<?= $h === $dtHour ? ' selected' : '' ?>><?= $hv ?></option>
<?php for ($dtH = 0; $dtH < 24; $dtH++): $hv = sprintf('%02d', $dtH); ?>
<option value="<?= $hv ?>"<?= $dtH === $dtHour ? ' selected' : '' ?>><?= $hv ?></option>
<?php endfor; ?>
</select>
<span class="dt-colon" aria-hidden="true">:</span>
<select class="dt-minute" name="<?= e($dtName) ?>_minute" aria-label="分">
<?php for ($m = 0; $m < 60; $m++): $mv = sprintf('%02d', $m); ?>
<option value="<?= $mv ?>"<?= $m === $dtMin ? ' selected' : '' ?>><?= $mv ?></option>
<?php /* ⚠️ 循环变量一律带 dt 前缀:这里曾被写成 $m,把宿主模板里
表示「观影记录」的 $m 覆盖成了 int —— 编辑页的简介/备注回填
被静默清空、取消链接变 id=0,还不止一个 Warning(2026-09-30 e2e 抓到)。 */ ?>
<?php for ($dtM = 0; $dtM < 60; $dtM++): $mv = sprintf('%02d', $dtM); ?>
<option value="<?= $mv ?>"<?= $dtM === $dtMin ? ' selected' : '' ?>><?= $mv ?></option>
<?php endfor; ?>
</select>
<?php if ($dtSeconds): ?>
<span class="dt-colon" aria-hidden="true">:</span>
<select class="dt-second" name="<?= e($dtName) ?>_second" aria-label="秒">
<?php for ($s = 0; $s < 60; $s++): $sv = sprintf('%02d', $s); ?>
<option value="<?= $sv ?>"<?= $s === $dtSec ? ' selected' : '' ?>><?= $sv ?></option>
<?php for ($dtS = 0; $dtS < 60; $dtS++): $sv = sprintf('%02d', $dtS); ?>
<option value="<?= $sv ?>"<?= $dtS === $dtSec ? ' selected' : '' ?>><?= $sv ?></option>
<?php endfor; ?>
</select>
<?php endif; ?>
@@ -59,5 +62,6 @@ $dtSec = $dtTs ? (int)date('s', $dtTs) : 0;
</div>
<?php /* 用完即清,避免残留变量影响下一次 include */ unset(
$dtName, $dtValue, $dtRequired, $dtId, $dtSeconds,
$dtTs, $dtDate, $dtHour, $dtMin, $dtSec
$dtTs, $dtDate, $dtHour, $dtMin, $dtSec,
$dtH, $dtM, $dtS, $hv, $mv, $sv
); ?>
+2 -1
View File
@@ -7,6 +7,7 @@ $navItems = [
'kin' => ['家谱', 'kin'],
'people' => ['名册', 'people'],
'movies' => ['观影', 'movies'],
'cinemas' => ['影院', 'movies/cinemas'],
];
// ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的,
// 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。
@@ -37,7 +38,7 @@ $isLoggedIn = \App\Auth::check();
<span>家书</span>
</a>
<nav class="nav">
<?php foreach ($navItems as $route => list($label, $key)): ?>
<?php foreach ($navItems as $key => list($label, $route)): ?>
<a class="<?= $pageKey === $key ? 'on' : '' ?>" href="<?= e(url($route)) ?>"><?= e($label) ?></a>
<?php endforeach; ?>
</nav>
+136
View File
@@ -0,0 +1,136 @@
<?php
/**
* 影院管理(v9,MovieLog cinemas.php 同款):上面加/编表单,下面列表。
*
* 颜色用「取色器 + 50 色预设板」两路输入(内联小段 JS 只是取色联动,
* 不依赖 JS 也能手填 hex 或直接用取色器)。删除是 POST 小表单 + confirm;
* 删掉的影院只把关联观影记录的 cinema_id 置空,记录本身不动。
*/
$editing = !empty($editing);
$canWrite = \App\Auth::canWrite();
?>
<div class="page-head">
<h1>影院管理</h1>
<span class="sub">共 <?= count($cinemas) ?> 家影院</span>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies')) ?>">返回观影记录</a>
</div>
</div>
<?php if (!$canWrite): ?>
<div class="card card-pad empty"><b>只读账号</b>访客只能查看影院列表。</div>
<?php else: ?>
<div class="card card-pad cinema-form-card">
<h2 class="cinema-h2"><?= $editing ? '编辑影院' : '添加影院' ?></h2>
<form method="post" action="<?= e(url('movies/cinemas_save')) ?>">
<?= csrf_field() ?>
<?php if ($editing): ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<div class="field">
<label for="cname">影院名称 *</label>
<input class="input" id="cname" name="name" required
value="<?= e(old('name', $editing['name'] ?? '')) ?>" placeholder="如:万达影城(IMAX 店)">
</div>
<div class="field">
<label for="cplace">所在地方</label>
<input class="input" id="cplace" name="place"
value="<?= e(old('place', $editing['place'] ?? '')) ?>" placeholder="如:青岛 · 崂山区">
</div>
<div class="field full">
<label for="caddress">详细地址</label>
<input class="input" id="caddress" name="address"
value="<?= e(old('address', $editing['address'] ?? '')) ?>">
</div>
<div class="field full">
<label for="ccolor">主题颜色</label>
<div class="color-row">
<input type="color" id="ccolor" name="color" class="color-picker"
value="<?= e(old('color', $editing['color'] ?? '#888888')) ?>">
<span class="color-hex" id="colorHex"><?= e(old('color', $editing['color'] ?? '#888888')) ?></span>
<span class="preset-palette">
<?php foreach (\App\Cinema::PRESET_COLORS as $pc): ?>
<span class="preset-color" data-color="<?= $pc ?>" style="background: <?= $pc ?>;" title="<?= $pc ?>"></span>
<?php endforeach; ?>
</span>
</div>
</div>
</div>
<div class="btn-row">
<button type="submit" class="btn"><?= $editing ? '保存' : '添加影院' ?></button>
<?php if ($editing): ?>
<a class="btn btn-ghost" href="<?= e(url('movies/cinemas')) ?>">取消编辑</a>
<?php endif; ?>
</div>
</form>
</div>
<?php endif; ?>
<div class="card card-pad">
<h2 class="cinema-h2">影院列表</h2>
<?php if (!$cinemas): ?>
<p class="empty-hint">还没有影院,先在上面添加一家。</p>
<?php else: ?>
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>名称</th><th>所在地方</th><th>详细地址</th><th>观影</th><?php if ($canWrite): ?><th>操作</th><?php endif; ?></tr>
</thead>
<tbody>
<?php foreach ($cinemas as $c): ?>
<tr>
<td>
<span class="color-preview" style="background: <?= e((string)($c['color'] ?: '#888888')) ?>;"></span>
<b><?= e($c['name']) ?></b>
</td>
<td><?= e((string)($c['place'] ?? '')) !== '' ? e($c['place']) : '—' ?></td>
<td><?= !empty($c['address']) ? e($c['address']) : '—' ?></td>
<td><?= (int)$c['movie_count'] ?> 场</td>
<?php if ($canWrite): ?>
<td>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies/cinemas', ['edit' => (int)$c['id']])) ?>">编辑</a>
<form method="post" action="<?= e(url('movies/cinemas_del')) ?>"
onsubmit="return confirm('确定删除「<?= e($c['name']) ?>」吗?关联观影记录的影院信息将置空。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<button type="submit" class="btn btn-danger btn-sm">删除</button>
</form>
</div>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php if ($canWrite): ?>
<script>
(function () {
var picker = document.getElementById('ccolor');
var hex = document.getElementById('colorHex');
if (!picker || !hex) return;
function setActive(color) {
Array.prototype.forEach.call(document.querySelectorAll('.preset-color'), function (el) {
el.classList.toggle('active', el.getAttribute('data-color') === color);
});
}
picker.addEventListener('input', function () {
hex.textContent = picker.value;
setActive(picker.value);
});
Array.prototype.forEach.call(document.querySelectorAll('.preset-color'), function (el) {
el.addEventListener('click', function () {
picker.value = el.getAttribute('data-color');
hex.textContent = picker.value;
setActive(picker.value);
});
});
setActive(picker.value);
})();
</script>
<?php endif; ?>
+43 -60
View File
@@ -2,24 +2,31 @@
/**
* 观影表单(新增 / 编辑,一张表单两用 —— 与 people/form 同一思路)。
*
* 字段分三块:看什么(片名/日期/评分/感受)、跟谁(同行的人)、在哪看(影院三段),
* 再加自由文本(简介/观后感/备注)。海报与票根是 free 槽位(v8):
* 不进裁剪器、整图等比缩进框内,所以这里不放裁剪框,只放普通文件控件。
* v9 变化:观影时间改为「开始 / 结束」两个秒级时刻(_datetime.php 控件,
* 服务端 read_dt_input 取值);评分与观后感**移出表单** —— 搬到详情页的
* 「评价与评论」区(MovieLog 同款:每账号一条评价,谁都能评);影院改为
* 下拉选择已有影院 + 可选「新建影院」行内三段。
*
* 现场照片只在**新增**时提供多选;编辑时在详情页里逐张补/删 ——
* 一次改一处的表单,比一个巨型表单不容易出错。
* 海报与票根是 free 槽位(v8):不进裁剪器、整图等比缩进框内,普通文件控件即可。
* 现场照片只在**新增**时提供多选;编辑时在详情页里逐张补/删。
*/
$isEdit = !empty($m['id']);
// 回填:校验失败重画时用用户刚提交的值(old()),否则用库里的值
// 时间回填:校验失败重画时优先用 old() 里的分字段重组(否则用户刚选的时刻会丢);
// 正常进编辑页时用库里的值。
$dtFromOld = static function (string $name, ?string $dbValue): ?string {
$date = trim((string)old($name . '_date', ''));
if ($date !== '') {
return sprintf('%s %02d:%02d:%02d', $date,
(int)old($name . '_hour', 0), (int)old($name . '_minute', 0), (int)old($name . '_second', 0));
}
return ($dbValue !== null && $dbValue !== '') ? $dbValue : null;
};
$dtStart = $dtFromOld('starts_at', $m['starts_at'] ?? null);
$dtEnd = $dtFromOld('ends_at', $m['ends_at'] ?? null);
$selCinema = (int)old('cinema_id', $m['cinema_id'] ?? 0);
$curAttendees = array_map('intval', array_column($m['attendees'] ?? [], 'id'));
$curFeelings = [];
if (!empty($m['feelings'])) {
$curFeelings = json_decode((string)$m['feelings'], true) ?: [];
}
$selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
$selFeelings = (array)old('feelings', $curFeelings);
$rtVal = old('rating', $m['rating'] ?? '');
?>
<div class="page-head">
<h1><?= $isEdit ? '编辑观影记录' : '记一场电影' ?></h1>
@@ -41,34 +48,34 @@ $rtVal = old('rating', $m['rating'] ?? '');
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div>
<div class="field">
<label for="starts_at">观影日期</label>
<input class="input" id="starts_at" name="starts_at" type="date"
value="<?= e(old('starts_at', $m['starts_at'] ?? '')) ?>">
</div>
<div class="field">
<label for="rating">评分(1-10)</label>
<select class="select" id="rating" name="rating">
<option value="">不评</option>
<?php for ($i = 10; $i >= 1; $i--): ?>
<option value="<?= $i ?>" <?= (string)$rtVal === (string)$i ? 'selected' : '' ?>><?= $i ?></option>
<?php endfor; ?>
</select>
<div class="field full">
<label <?= $isEdit ? '' : 'for="starts_at_date"' ?>>开始时间(精确到秒)</label>
<?php $dtName = 'starts_at'; $dtValue = $dtStart; $dtId = 'starts_at_date';
require __DIR__ . '/../_datetime.php'; ?>
<?php if (!empty($errors['starts_at'])): ?><span class="err"><?= e($errors['starts_at']) ?></span><?php endif; ?>
</div>
<div class="field full">
<label>观后感受</label>
<div class="feel-grid">
<?php foreach (\App\Movie::FEELINGS as $f): ?>
<label class="feel-item">
<input type="checkbox" name="feelings[]" value="<?= e($f) ?>"
<?= in_array($f, $selFeelings, true) ? 'checked' : '' ?>>
<span><?= e($f) ?></span>
</label>
<?php endforeach; ?>
<label>结束时间(散场,可不填)</label>
<?php $dtName = 'ends_at'; $dtValue = $dtEnd; $dtId = '';
require __DIR__ . '/../_datetime.php'; ?>
<?php if (!empty($errors['ends_at'])): ?><span class="err"><?= e($errors['ends_at']) ?></span><?php endif; ?>
</div>
<div class="field full">
<label for="cinema_id">影院</label>
<div class="cinema-select-row">
<select class="select" id="cinema_id" name="cinema_id">
<option value="0">不记录影院</option>
<?php foreach ($cinemas as $c): ?>
<option value="<?= (int)$c['id'] ?>" <?= $selCinema === (int)$c['id'] ? 'selected' : '' ?>>
<?= e(trim(((string)($c['place'] ?? '') !== '' ? $c['place'] . ' · ' : '') . $c['name'])) ?>
</option>
<?php endforeach; ?>
</select>
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies/cinemas')) ?>">管理影院</a>
</div>
<span class="tip">MovieLog 里记的英文标签(warm / excited…)搬过来时已经翻成中文。</span>
<span class="tip">想记一家还没录过的影院?先去「管理影院」里添加,回来这里就能选。</span>
</div>
<div class="field full">
@@ -88,36 +95,12 @@ $rtVal = old('rating', $m['rating'] ?? '');
<?php endif; ?>
</div>
<div class="field">
<label for="cinema_place">影院 · 城市/商圈</label>
<input class="input" id="cinema_place" name="cinema_place"
value="<?= e(old('cinema_place', $m['cinema_place'] ?? '')) ?>" placeholder="如:青岛 · 崂山区">
</div>
<div class="field">
<label for="cinema_name">影院 · 名字</label>
<input class="input" id="cinema_name" name="cinema_name"
value="<?= e(old('cinema_name', $m['cinema_name'] ?? '')) ?>" placeholder="如:万达影城 CBD 店">
</div>
<div class="field full">
<label for="cinema_address">影院 · 地址</label>
<input class="input" id="cinema_address" name="cinema_address"
value="<?= e(old('cinema_address', $m['cinema_address'] ?? '')) ?>">
</div>
<div class="field full">
<label for="info">简介</label>
<textarea class="input" id="info" name="info" rows="3"
placeholder="剧情简介(可选)"><?= e(old('info', $m['info'] ?? '')) ?></textarea>
</div>
<div class="field full">
<label for="review">观后感</label>
<textarea class="input" id="review" name="review" rows="4"
placeholder="看完想记下来的话"><?= e(old('review', $m['review'] ?? '')) ?></textarea>
</div>
<div class="field full">
<label for="note">备注</label>
<textarea class="input" id="note" name="note" rows="2"><?= e(old('note', $m['note'] ?? '')) ?></textarea>
+58 -40
View File
@@ -1,61 +1,79 @@
<?php
/**
* 观影时间线 —— 按年分组倒序(v8,MovieLog 迁入后的新家)。
* 观影瀑布流(v9,MovieLog 同款海报墙)。
*
* 没填日期的记录落在键 0,沉到最底下单独一组「日期待补」,
* 不让它混进任何一个年份里(跟名册「未分类」栏是同一个思路)。
* 卡片 = 海报(2:3,无海报给渐变占位)+ 片名 + 日期/影院(主题色)/评分均分 + 同行人头像标签。
* 顶部搜索栏:关键词搜片名 / 影院 / 同行人(Movie::all 的 LIKE 查询)。
*/
$canWrite = \App\Auth::canWrite();
$fmtNum = static function ($v): string {
if ($v === null) {
return '';
}
return rtrim(rtrim(number_format((float)$v, 1, '.', ''), '0'), '.');
};
?>
<div class="page-head">
<h1>观影记录</h1>
<span class="sub">一共 <?= (int)$total ?> 场</span>
<span class="sub">
<?php if ($keyword !== ''): ?>
找到 <?= (int)$total ?> 部匹配「<b><?= e($keyword) ?></b>」的电影
<?php else: ?>
共 <?= (int)$total ?> 部 · 按观影时间排序
<?php endif; ?>
</span>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies/cinemas')) ?>">影院管理</a>
<?php if ($canWrite): ?>
<a class="btn" href="<?= e(url('movies/new')) ?>">记一场电影</a>
<?php endif; ?>
</div>
</div>
<form method="get" action="index.php" class="search-bar">
<input type="hidden" name="r" value="movies">
<input type="text" name="keyword" value="<?= e($keyword) ?>" placeholder="搜索片名、影院、观影人…" aria-label="搜索">
<button type="submit">搜索</button>
<?php if ($keyword !== ''): ?>
<a class="search-clear" href="<?= e(url('movies')) ?>">清除</a>
<?php endif; ?>
</form>
<?php if ($total === 0): ?>
<div class="card card-pad empty">
<b>还没有观影记录</b>
一家人一起看过的电影,都会按年份排在这里。
<b><?= $keyword !== '' ? '没有找到匹配的电影' : '还没有观影记录' ?></b>
<?= $keyword !== '' ? '换个关键词试试。' : '一家人一起看过的电影,都会以海报墙的方式排在这里。' ?>
</div>
<?php else: ?>
<?php foreach ($years as $y => $list): ?>
<section class="section">
<h2 class="section-title"><?= $y > 0 ? (int)$y : '日期待补' ?> <span class="hint"><?= count($list) ?> 场</span></h2>
<div class="movie-list">
<?php foreach ($list as $mv): ?>
<?php
// 评分统一一位小数内显示:9 → "9",8.5 → "8.5"(库里是 0-10 整数,这里只做展示兜底)
$rtTxt = $mv['rating'] !== null
? rtrim(rtrim(number_format((float)$mv['rating'], 1, '.', ''), '0'), '.')
: '';
?>
<a class="movie-card" href="<?= e(url('movies/show', ['id' => (int)$mv['id']])) ?>">
<?php if (!empty($mv['poster_key'])): ?>
<img class="m-poster" src="<?= e(media_url($mv['poster_key'])) ?>" alt="" loading="lazy">
<?php else: ?>
<span class="m-poster m-poster-empty" aria-hidden="true">影</span>
<?php endif; ?>
<span class="m-main">
<span class="m-title">
<?= e($mv['title']) ?>
<?php if ($rtTxt !== ''): ?><span class="rating"><?= e($rtTxt) ?></span><?php endif; ?>
</span>
<span class="m-meta">
<?= e((string)($mv['starts_at'] ?? '日期待补')) ?>
<?php if (!empty($mv['cinema_name'])): ?> · <?= e($mv['cinema_name']) ?><?php endif; ?>
</span>
<?php if (!empty($mv['attendee_names'])): ?>
<span class="m-who">和 <?= e($mv['attendee_names']) ?></span>
<?php endif; ?>
<div class="waterfall">
<?php foreach ($rows as $mv): ?>
<a class="movie-card" href="<?= e(url('movies/show', ['id' => (int)$mv['id']])) ?>">
<span class="card-image">
<?php if (!empty($mv['poster_key'])): ?>
<img src="<?= e(media_url($mv['poster_key'])) ?>" alt="<?= e($mv['title']) ?>" loading="lazy">
<?php else: ?>
<span class="placeholder-poster">
<span class="ph-icon" aria-hidden="true">影</span>
<span class="placeholder-title"><?= e($mv['title']) ?></span>
</span>
</a>
<?php endforeach; ?>
</div>
</section>
<?php endforeach; ?>
<?php endif; ?>
</span>
<span class="card-body">
<span class="card-title"><?= e($mv['title']) ?></span>
<span class="card-meta">
<span class="meta-date">📅 <?= e(\App\Movie::formatWatch($mv['starts_at'], $mv['ends_at'])) ?></span>
<?php if (!empty($mv['cinema_name'])): ?>
<span class="meta-cinema" style="color: <?= e((string)($mv['cinema_color'] ?: '#888888')) ?>;">🏛️ <?= e($mv['cinema_name']) ?></span>
<?php endif; ?>
<?php if (!empty($mv['review_count'])): ?>
<span class="meta-rating" title="共 <?= (int)$mv['review_count'] ?> 条评价">⭐ <?= e($fmtNum($mv['review_avg'])) ?><span class="meta-rating-max">/10</span></span>
<?php endif; ?>
</span>
<?php if (!empty($mv['attendee_names'])): ?>
<span class="card-viewers"><span class="viewer-tag">👥 <?= e($mv['attendee_names']) ?></span></span>
<?php endif; ?>
</span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
+432 -77
View File
@@ -1,25 +1,126 @@
<?php
/**
* 观影详情页。
* 观影详情页(v9,展示方式照搬 MovieLog 的 detail.php)。
*
* 海报 / 票根 / 照片一律走 media_url() 代理(全站唯一出口,见 helpers.php);
* 感受标签是迁移时已翻成中文的 JSON 数组,这里只展示不加工。
* 访客(只读)看不到编辑/删除/传照片那些表单 —— 与名册同一口径。
* 五大块:英雄区(左海报右信息)→ 一起看的人 → 电影院信息 → 影像记录(灯箱)→ 评价与评论。
* 海报/票根/照片一律走 media_url() 代理;感受标签只存 key,emoji 与文案查 CATALOG。
* 访客(只读)看不到编辑/删除/传照片/评价表单 —— 与名册同一口径。
* 评价人是「登录账号背后的人」,与同行观影人无关(MovieLog 同款规则)。
*/
$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']);
$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();
};
/** 我的评价表单(打分 + 正文 + 感受),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>
<span class="rv-scale" role="radiogroup" aria-label="评分,1 分到 10 分">
<?php $myRating = (int)($myReview['rating'] ?? 0); ?>
<?php for ($i = 1; $i <= 10; $i++): ?>
<label class="rv-num" title="<?= $i ?> 分">
<input type="radio" name="rating" value="<?= $i ?>"<?= $myRating === $i ? ' checked' : '' ?>>
<span><?= $i ?></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">
<h1><?= e($m['title']) ?></h1>
<span class="sub"><?= e((string)($m['starts_at'] ?? '日期待补')) ?></span>
<span class="sub">观影记录</span>
<div class="actions">
<?php if ($canWrite): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies/edit', ['id' => (int)$m['id']])) ?>">编辑</a>
@@ -30,80 +131,90 @@ $hasTicket = !empty($m['ticket_key']);
<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>
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies')) ?>">返回列表</a>
</div>
</div>
<div class="movie-detail">
<div class="m-poster-col">
<!-- 主信息:英雄区 -->
<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): ?>
<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>
<img src="<?= e(media_url($m['poster_key'])) ?>" alt="海报">
<?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>
<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="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>
<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; ?>
<?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>
<span>👥 观影人 <?= count($m['attendees']) ?> 人</span>
</div>
<?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>
<div class="info-text"><?= nl2br(e($m['info'])) ?></div>
<?php endif; ?>
<?php if (!empty($m['note'])): ?>
<div class="m-block"><h3>备注</h3><p><?= nl2br(e($m['note'])) ?></p></div>
<div class="remark-text">📝 <?= nl2br(e($m['note'])) ?></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>
<!-- 一起看的人 -->
<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): ?>
<a class="viewer-tag" href="<?= e(url('people/show', ['id' => (int)$a['id']])) ?>">
<?= $avatarHtml($a['avatar'], (string)$a['name']) ?>
<?= e($a['name']) ?>
</a>
<?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>
<!-- 影像记录(票根 + 现场照片,点开灯箱) -->
<?php if ($hasTicket || $m['photos']): ?>
<div class="detail-section">
<h3>影像记录 <span class="badge"><?= count($m['photos']) + ($hasTicket ? 1 : 0) ?> 张</span></h3>
<div class="gallery-grid">
<?php if ($hasTicket): ?>
<div class="gallery-item lb-openable" data-lb-index="1" role="button" tabindex="0" aria-label="放大查看票根" title="点击查看大图">
<img src="<?= e(media_url($m['ticket_key'])) ?>" alt="票根" loading="lazy">
<span class="label-badge">票根</span>
</div>
<?php endif; ?>
<?php foreach ($m['photos'] as $i => $ph): ?>
<div class="gallery-item lb-openable" data-lb-index="<?= $i + 2 ?>" role="button" tabindex="0" aria-label="放大查看照片" title="点击查看大图">
<img src="<?= e(media_url((string)$ph['key'])) ?>" alt="" loading="lazy">
<span class="label-badge">照片</span>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('movies/photo_del')) ?>"
onsubmit="return confirm('删除这张照片?')">
@@ -116,12 +227,11 @@ $hasTicket = !empty($m['ticket_key']);
</div>
<?php endforeach; ?>
</div>
</section>
</div>
<?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">
<div class="detail-section">
<form method="post" enctype="multipart/form-data" action="<?= e(url('movies/photo_add')) ?>" class="photo-add">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
<label for="photo">补一张现场照片</label>
@@ -130,5 +240,250 @@ $hasTicket = !empty($m['ticket_key']);
<button type="submit" class="btn btn-ghost btn-sm">上传</button>
</div>
</form>
</section>
</div>
<?php endif; ?>
<!-- 评价与评论 -->
<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">
<span class="rv-name"><?= e($rvName) ?></span>
<?php if ($rvMine): ?><span class="rv-mine">我</span><?php endif; ?>
<span class="rv-time"><?= e(substr((string)$rv['created_at'], 0, 16)) ?></span>
</span>
<span class="rv-score" title="<?= e(\App\MovieReview::ratingWord($rvRate)) ?>"><?= $rvRate ?><span class="rv-score-max">/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>
<?php if ($cMine): ?><span class="rv-mine">我</span><?php endif; ?>
<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>
<?php if ($rMine): ?><span class="rv-mine">我</span><?php endif; ?>
<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 || $hasTicket || $m['photos']): ?>
<!-- 图片灯箱:点任意一张图在当前页弹出,可左右翻看 / Esc 关闭(MovieLog 同款) -->
<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 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 if ($hasTicket): ?>
items.push({url: <?= json_encode(media_url((string)$m['ticket_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 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 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;
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 也能选 -->
<script>
(function () {
var form = document.getElementById('reviewForm');
var word = document.getElementById('rvWord');
if (!form || !word) return;
var WORDS = <?= json_encode(\App\MovieReview::ratingWords(), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
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>
<?php endif; ?>