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
+195 -34
View File
@@ -13,11 +13,13 @@ require __DIR__ . '/app/bootstrap.php';
use App\Account;
use App\Auth;
use App\Birthday;
use App\Cinema;
use App\Crypto;
use App\Database;
use App\Family;
use App\Kinship;
use App\Movie;
use App\MovieReview;
use App\Nickname;
use App\Person;
use App\PersonGroup;
@@ -1337,21 +1339,16 @@ switch ($r) {
// 见 App\Movie。可见性与名册同一口径:能登录就能看,能写才能改;
// 迁进来的历史记录 source_ref = 'movielog:movie:<id>'(重跑迁移的幂等锚点)。
/** 观影时间线 —— 按年分组倒序。 */
/** 观影瀑布流(v9,MovieLog 同款海报墙;带关键词搜索)。 */
case 'movies':
$familyId = (int)Auth::familyId();
$rows = Movie::all($familyId);
$years = [];
foreach ($rows as $r) {
$y = $r['starts_at'] !== null ? (int)substr((string)$r['starts_at'], 0, 4) : 0;
$years[$y][] = $r;
}
unset($r);
krsort($years);
$keyword = trim((string)($_GET['keyword'] ?? ''));
$rows = Movie::all($familyId, $keyword);
View::render('movies/index', [
'title' => '观影记录',
'years' => $years,
'total' => count($rows),
'title' => '观影记录',
'rows' => $rows,
'total' => count($rows),
'keyword' => $keyword,
], 'movies');
break;
@@ -1362,19 +1359,28 @@ switch ($r) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
$meId = (int)Auth::personId();
$rvError = '';
View::render('movies/show', [
'title' => (string)$m['title'],
'm' => $m,
'title' => (string)$m['title'],
'm' => $m,
'reviews' => MovieReview::forEvent((int)$m['id']),
'rvStats' => MovieReview::stats((int)$m['id']),
'myReview' => MovieReview::mine((int)$m['id'], $meId),
'meId' => $meId,
'rvError' => $rvError,
'feelCat' => MovieReview::CATALOG,
], 'movies');
break;
case 'movies/new':
Auth::requireWrite();
View::render('movies/form', [
'title' => '记一场电影',
'm' => null,
'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''),
'errors' => [],
'title' => '记一场电影',
'm' => null,
'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''),
'cinemas' => Cinema::all((int)Auth::familyId()),
'errors' => [],
], 'movies');
break;
@@ -1387,10 +1393,11 @@ switch ($r) {
redirect('movies');
}
View::render('movies/form', [
'title' => '编辑观影记录',
'm' => $m,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => [],
'title' => '编辑观影记录',
'm' => $m,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'cinemas' => Cinema::all($familyId),
'errors' => [],
], 'movies');
break;
@@ -1410,18 +1417,22 @@ switch ($r) {
redirect('movies');
}
// 起止时刻精确到秒(_datetime.php 控件 → read_dt_input)
$startsAt = read_dt_input('starts_at');
$endsAt = read_dt_input('ends_at');
$cinemaId = (int)($_POST['cinema_id'] ?? 0);
if ($cinemaId > 0 && !Cinema::find($familyId, $cinemaId)) {
$cinemaId = 0; // 表单可改:只认本家的影院
}
$d = [
'title' => trim((string)($_POST['title'] ?? '')),
'starts_at' => trim((string)($_POST['starts_at'] ?? '')),
'starts_at' => (string)($startsAt ?? ''),
'ends_at' => (string)($endsAt ?? ''),
'location' => trim((string)($_POST['location'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')),
'info' => trim((string)($_POST['info'] ?? '')),
'rating' => trim((string)($_POST['rating'] ?? '')),
'review' => trim((string)($_POST['review'] ?? '')),
'feelings' => array_values((array)($_POST['feelings'] ?? [])),
'cinema_place' => trim((string)($_POST['cinema_place'] ?? '')),
'cinema_name' => trim((string)($_POST['cinema_name'] ?? '')),
'cinema_address' => trim((string)($_POST['cinema_address'] ?? '')),
'cinema_id' => $cinemaId,
// 同行的人:只认「确实存在且属于这个家」的 id —— 表单可以改,
// 不能凭它把别人家的成员挂进自己家的观影记录里(会顺着详情页漏人名)。
'attendees' => array_map('intval', (array)($_POST['attendees'] ?? [])),
@@ -1429,7 +1440,13 @@ switch ($r) {
$_familyPeople = Person::map($familyId);
$d['attendees'] = array_values(array_filter($d['attendees'],
static fn($pid) => isset($_familyPeople[$pid])));
$errors = $d['title'] === '' ? ['title' => '片名不能为空'] : [];
$errors = [];
if ($d['title'] === '') {
$errors['title'] = '片名不能为空';
} elseif ($d['starts_at'] !== '' && $d['ends_at'] !== ''
&& strcmp($d['ends_at'], $d['starts_at']) < 0) {
$errors['ends_at'] = '散场时间不能早于开场时间';
}
// ⚠️ 图片上传失败**不挡保存**(与头像同一思路:一条记录因为一张 5MB 原图
// 存不进去,看起来像功能坏了)。现在是先存记录,图片的事单独提示。
@@ -1479,10 +1496,11 @@ switch ($r) {
if ($errors) {
set_old($_POST);
View::render('movies/form', [
'title' => $isEdit ? '编辑观影记录' : '记一场电影',
'm' => $m,
'people' => Person::roster($familyId, $me, 'member', ''),
'errors' => $errors,
'title' => $isEdit ? '编辑观影记录' : '记一场电影',
'm' => $m,
'people' => Person::roster($familyId, $me, 'member', ''),
'cinemas' => Cinema::all($familyId),
'errors' => $errors,
], 'movies');
exit;
}
@@ -1552,6 +1570,149 @@ switch ($r) {
redirect('movies');
break;
// ---------------- 影院管理(v9,MovieLog 同款) ----------------
/** 影院管理单页:上面加/编表单,下面列表。GET 带参数时进入编辑态。 */
case 'movies/cinemas':
$familyId = (int)Auth::familyId();
$editId = (int)($_GET['edit'] ?? 0);
$editing = $editId > 0 ? Cinema::find($familyId, $editId) : null;
View::render('movies/cinemas', [
'title' => '影院管理',
'cinemas' => Cinema::all($familyId),
'editing' => $editing,
], 'cinemas');
break;
case 'movies/cinemas_save':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$editing = $id > 0 ? Cinema::find($familyId, $id) : null;
if ($id > 0 && !$editing) {
flash('err', '找不到这家影院。');
redirect('movies/cinemas');
}
$err = Cinema::save(
$familyId,
$id,
trim((string)($_POST['place'] ?? '')),
trim((string)($_POST['name'] ?? '')),
trim((string)($_POST['address'] ?? '')) !== '' ? trim((string)($_POST['address'] ?? '')) : null,
Cinema::normalizeColor((string)($_POST['color'] ?? '#888888'))
);
if ($err !== '') {
flash('err', $err);
redirect('movies/cinemas', $id > 0 ? ['edit' => $id] : []);
}
flash('ok', $id > 0 ? '影院信息已更新。' : '影院已添加。');
redirect('movies/cinemas');
break;
case 'movies/cinemas_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (Cinema::find($familyId, $id)) {
Cinema::destroy($familyId, $id);
flash('ok', '影院已删除,关联观影记录的影院信息已置空。');
} else {
flash('err', '找不到这家影院。');
}
redirect('movies/cinemas');
break;
// ---------------- 评价与讨论(v9,MovieLog 同款) ----------------
// 评价人是「登录账号背后的人」,与同行观影人无关;访客只读(requireWrite)。
case 'movies/review_save':
$familyId = (int)Auth::familyId();
$meId = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$eventId = (int)($_POST['id'] ?? 0);
$m = Movie::find($familyId, $eventId);
if (!$m) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
$my = MovieReview::mine($eventId, $meId);
$err = MovieReview::save(
$eventId, $familyId, $meId,
(int)($_POST['rating'] ?? 0),
trim((string)($_POST['content'] ?? '')),
array_values((array)($_POST['feelings'] ?? [])),
$my !== null
);
if ($err !== '') {
flash('err', $err);
} else {
flash('ok', $my !== null ? '评价已更新。' : '评价已发布。');
}
redirect('movies/show', ['id' => $eventId], 'reviews');
break;
case 'movies/review_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$eventId = (int)($_POST['id'] ?? 0);
if (!Movie::find($familyId, $eventId)) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
if (!MovieReview::deleteMine((int)($_POST['review_id'] ?? 0), (int)Auth::personId())) {
flash('err', '只能删除自己的评价。');
} else {
flash('ok', '评价已删除。');
}
redirect('movies/show', ['id' => $eventId], 'reviews');
break;
case 'movies/comment_add':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$eventId = (int)($_POST['id'] ?? 0);
$reviewId = (int)($_POST['review_id'] ?? 0);
$parentId = (int)($_POST['parent_id'] ?? 0);
if (!Movie::find($familyId, $eventId)) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
$res = MovieReview::addComment(
$reviewId, (int)Auth::personId(),
trim((string)($_POST['content'] ?? '')),
array_values((array)($_POST['feelings'] ?? [])),
$parentId
);
if (is_string($res)) {
flash('err', $res);
} else {
flash('ok', $parentId > 0 ? '回复已发表。' : '评论已发表。');
}
redirect('movies/show', ['id' => $eventId], 'reviews');
break;
case 'movies/comment_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$eventId = (int)($_POST['id'] ?? 0);
if (!Movie::find($familyId, $eventId)) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
if (!MovieReview::deleteComment((int)($_POST['comment_id'] ?? 0), (int)Auth::personId())) {
flash('err', '只能删除自己的评论。');
} else {
flash('ok', '评论已删除。');
}
redirect('movies/show', ['id' => $eventId], 'reviews');
break;
default:
http_response_code(404);
View::render('error', [