Files
FamilyBook/app/Movie.php
T
laoyang 71a7ec35a0 v14: 观影/家谱/日程/旅程/资源/任务六模块统一,首页改 dashboard
用户 14 条需求一次交付(1-13 功能 + 14 工作方式)。

观影(1-4)
- 返回文案全模块统一为「返回观影」(不再有「返回列表」「返回观影记录」三种叫法)
- 观影人一律用彩色人物标签 person_chip:底色由头像主色算出来(上传时存库),
  瀑布流卡片也按人显示;五处观影模板全部改用它
- 人员显示统一走 person_name()(shown → 我给他起的称呼 → 姓名)
- 影院管理页与年度报告页的上下两块用 .card + .card 统一给间距(原来紧贴成一整块)

家谱(5)
- 树根从「我」换成**辈分最高的祖先**:库画的是"树根的祖先链+后代+树根的兄弟姐妹",
  根=我 时伯父/堂兄/嫂子/侄子一个都不画(12 人夹具只画出 8 人)
- focusMap 按**代数**圈定三代(不再按步数),normalizeLinks 把子女对称挂到父母双方
  —— 修掉"母亲登录看不到孩子"
- 新增「看全部 / 回到我」两个按钮,放在画布外(画布整块被库接管了手势)

统一事件原语(6-9)
- 旅程:TripLog 全量迁入(3 趟旅程 / 21 段行程 / 72 张照片 / 5 个人)。
  复用 events(kind='trip') + trip_details + trip_nodes(计划/执行双轨保留)
- 日程:每人可建日历本(自定义色)+ 共享只读/可写;观影与旅行是每人默认两本,
  内容是**从各模块实时派生**的(不复制,所以改了观影日期日历立刻跟着变);
  没有具体时分 = 全天;月视图参照群晖 Calendar
- 资源:挂在某人名下的附属信息(标题+正文+多附件),可共享给多人并设只读/读写,
  默认折叠、可搜标题与正文
- 任务:看板三列 + 两级子任务 + 评论@人 + 共享 + 循环(完成时按原截止日推下一期)
- 统一附件表:四个模块的附件共用一张,下载地址不落库、一律由 media.php 现算

模块化(10-11)
- App\Module 是模块的唯一来源(加一行 = 导航条/主题色/个人设置三处自动出现)
- 个人设置里可勾选要显示哪些模块、拖动排序(关掉的模块**不会**被兜底补回来)
- 每个模块一套主题色(家庭级),只覆盖 --brand 三兄弟,不碰纸墨底色

其他(12-13)
- 新增成员表单可选所属分组(编辑表单没有:分组是"我和他的关系",只归我自己)
- 首页改 dashboard:接下来的日子 / 未来七天 / 手头在做 / 往年今日。
  家人卡片从首页撤走(看人去名册)

验证(全部通过)
- e2e 542 条(新增 4 模块端到端 60+ 条)
- 桌面几何 368 / 移动端几何 520 / 家谱 12 / 交互 97 / 老库升级 123
- SQL 列名静态校验 0 处可疑(对着真实表结构)
- 反向验证 18 条新注入全部抓住

顺带修掉的既有问题
- 老库升级回填 SQL 按实际存在的列动态拼(缺列不再 1054 炸断升级)
- 全新安装时 Schema::ensure 跑在"人还没建"之前 → 补建日历本三处齐备
- 窄屏导航横向溢出(模块从 4 个加到 8 个后顶栏撑破视口)
2026-10-02 09:28:07 +08:00

356 lines
14 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
/**
* 观影记录(统一事件原语的第一个落地种类)。
*
* 归一模型(见 familybook.md 的核心设计):
* events = 一切记录的共同骨架(标题 / 起止时刻 / 地点 / 来源引用)
* movie_details = 观影特有的卫星表(海报 / 票根 / 关联影院)
* attendees = 同行观影的人(persons.id)
* photos = 现场照片(可多张)
* reviews = 评价与讨论(movie_reviews / movie_review_comments,见 MovieReview)
* cinemas = 影院独立管理(见 Cinema)
*
* 图片一律存**相对键**(如 posters/xxx.jpg),地址由 media.php 代理 ——
* 本地与 OSS 的差别被 Storage 吃掉,这里永远不碰完整 URL。
*
* source_ref 是迁移幂等的锚点:MovieLog 整体迁入时写成 'movielog:movie:<id>',
* 重跑迁移按它先查再插,不会出现一半新一半旧的中间态。
*/
declare(strict_types=1);
namespace App;
final class Movie
{
/** @return array<int,array> 观影记录,按开场时刻倒序(瀑布流用)。
* $keyword 非空时搜:片名 / 影院名 / 同行人名(LIKE)。
*
* ⚠️ 列表上的「观影人」是**每人一个标签**(头像 + 底色 + 称呼),
* 不再拼成一串名字 —— 所以这里用 attachAttendees() 补出 `attendees`,
* 而不是 SQL 里 GROUP_CONCAT 出一个 attendee_names 字符串。 */
public static function all(int $familyId, string $keyword = '', int $viewerId = 0): array
{
$sql =
'SELECT e.id, e.title, e.starts_at, e.ends_at, e.note,
d.poster_key, c.name AS cinema_name, c.color AS cinema_color,
(SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS attendee_count
FROM events e
LEFT JOIN movie_details d ON d.event_id = e.id
LEFT JOIN cinemas c ON c.id = d.cinema_id
WHERE e.family_id = ? AND e.kind = "movie"';
$bind = [$familyId];
$kw = trim($keyword);
if ($kw !== '') {
$sql .= ' AND (e.title LIKE ? OR c.name LIKE ? OR e.location LIKE ?
OR EXISTS (SELECT 1 FROM event_attendees a JOIN persons p ON p.id = a.person_id
WHERE a.event_id = e.id AND p.name LIKE ?))';
$like = '%' . $kw . '%';
array_push($bind, $like, $like, $like, $like);
}
$sql .= ' ORDER BY e.starts_at DESC, e.id DESC';
$st = Database::get()->prepare($sql);
$st->execute($bind);
$rows = $st->fetchAll();
MovieReview::attachStats($rows);
self::attachAttendees($rows, $viewerId);
return $rows;
}
/**
* 给一批记录补上 `attendees`(每人一条:id / name / my_nickname / avatar /
* avatar_color)。**一次查询**补完,不逐条查 —— 瀑布流一页几十部电影,
* 逐条查就是几十次往返(N+1)。
*/
public static function attachAttendees(array &$rows, int $viewerId = 0): void
{
if (!$rows) {
return;
}
$ids = [];
foreach ($rows as $r) {
$ids[] = (int)$r['id'];
}
$ids = array_values(array_unique($ids));
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT a.event_id, p.id, p.name, p.avatar, p.avatar_color,
n.nickname AS my_nickname
FROM event_attendees a
JOIN persons p ON p.id = a.person_id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE a.event_id IN ($in)
ORDER BY a.position, p.id"
);
$st->execute(array_merge([$viewerId], $ids));
$map = [];
foreach ($st->fetchAll() as $r) {
$map[(int)$r['event_id']][] = $r;
}
foreach ($rows as $k => $r) {
$rows[$k]['attendees'] = $map[(int)$r['id']] ?? [];
}
}
/** 一条完整记录(详情/编辑页用):事件 + 卫星 + 影院 + 同行人 + 照片 */
public static function find(int $familyId, int $id, int $viewerId = 0): ?array
{
$st = Database::get()->prepare(
'SELECT e.*, d.info, d.poster_key, d.ticket_key, d.cinema_id, d.douban_meta,
c.place AS cinema_place, c.name AS cinema_name,
c.address AS cinema_address, c.color AS cinema_color
FROM events e
LEFT JOIN movie_details d ON d.event_id = e.id
LEFT JOIN cinemas c ON c.id = d.cinema_id
WHERE e.family_id = ? AND e.id = ? AND e.kind = "movie"'
);
$st->execute([$familyId, $id]);
$row = $st->fetch();
if (!$row) {
return null;
}
$row['attendees'] = self::attendees($id, $viewerId);
$row['photos'] = self::photos($id);
return $row;
}
/**
* 同行的人。带 `my_nickname` 与 `avatar_color` —— 页面上要按「我叫他什么」
* 显示、并用他自己的底色画出标签(见 helpers 的 person_name / person_bg)。
*/
public static function attendees(int $eventId, int $viewerId = 0): array
{
$st = Database::get()->prepare(
'SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname
FROM event_attendees a JOIN persons p ON p.id = a.person_id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE a.event_id = ?
ORDER BY a.position, p.id'
);
$st->execute([$viewerId, $eventId]);
return $st->fetchAll();
}
public static function photos(int $eventId): array
{
$st = Database::get()->prepare(
'SELECT id, `key` FROM event_photos WHERE event_id = ? ORDER BY position, id'
);
$st->execute([$eventId]);
return $st->fetchAll();
}
/**
* 新建一条观影记录,返回事件 id。
* $d 字段:title, starts_at(Y-m-d H:i:s), ends_at, location, note, info,
* cinema_id(int|null), poster_key, ticket_key, attendees(int[]), photos(string[])
*/
public static function create(int $familyId, ?int $creatorId, array $d): int
{
$db = Database::get();
$db->prepare(
'INSERT INTO events (family_id, creator_id, kind, title, starts_at, ends_at, location, note, source_ref)
VALUES (?, ?, "movie", ?, ?, ?, ?, ?, ?)'
)->execute([
$familyId, $creatorId, (string)$d['title'],
self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''),
self::locOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''),
$d['source_ref'] ?? null,
]);
$id = (int)$db->lastInsertId();
self::saveDetails($id, $d);
self::saveAttendees($id, $d['attendees'] ?? []);
self::savePhotos($id, $d['photos'] ?? []);
return $id;
}
public static function update(int $familyId, int $id, array $d): void
{
$db = Database::get();
$db->prepare(
'UPDATE events SET title = ?, starts_at = ?, ends_at = ?, location = ?, note = ?
WHERE id = ? AND family_id = ?'
)->execute([
(string)$d['title'],
self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''),
self::locOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''),
$id, $familyId,
]);
self::saveDetails($id, $d);
self::saveAttendees($id, $d['attendees'] ?? []);
// photos 由单独的「删除某张」路由维护,编辑保存不整组替换
}
/** 卫星表 upsert(编辑时可能还没建过这行) */
private static function saveDetails(int $eventId, array $d): void
{
$db = Database::get();
$cid = (int)($d['cinema_id'] ?? 0);
// 豆瓣元数据:进来是数组(表单提交的 JSON 已由路由解码校验),存成 JSON 文本;
// 空数组/缺省 = 不写(保持 NULL,详情页就不渲染豆瓣块)
$meta = isset($d['douban_meta']) && is_array($d['douban_meta']) && $d['douban_meta'] !== []
? json_encode($d['douban_meta'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
: null;
$row = [
self::textOrNull($d['info'] ?? ''),
self::keyOrNull($d['poster_key'] ?? ''),
self::keyOrNull($d['ticket_key'] ?? ''),
$cid > 0 ? $cid : null,
$meta,
];
$has = $db->prepare('SELECT COUNT(*) FROM movie_details WHERE event_id = ?');
$has->execute([$eventId]);
if ((int)$has->fetchColumn() > 0) {
$db->prepare('UPDATE movie_details SET info=?, poster_key=?, ticket_key=?, cinema_id=?, douban_meta=? WHERE event_id=?')
->execute([...$row, $eventId]);
} else {
$db->prepare('INSERT INTO movie_details (info, poster_key, ticket_key, cinema_id, douban_meta, event_id) VALUES (?,?,?,?,?,?)')
->execute([...$row, $eventId]);
}
}
/** 整组替换同行人(与 Person::replaceContacts 同一思路:一行不剩、一行不多) */
public static function saveAttendees(int $eventId, array $personIds): void
{
$db = Database::get();
$db->prepare('DELETE FROM event_attendees WHERE event_id = ?')->execute([$eventId]);
$ins = $db->prepare(
'INSERT IGNORE INTO event_attendees (event_id, person_id, position) VALUES (?, ?, ?)'
);
$pos = 0;
foreach ($personIds as $pid) {
$pid = (int)$pid;
if ($pid <= 0) {
continue;
}
$pos += 10;
$ins->execute([$eventId, $pid, $pos]);
}
}
/** 整组替换现场照片(导入用;表单的「删一张」走 deletePhoto) */
public static function savePhotos(int $eventId, array $keys): void
{
$db = Database::get();
$db->prepare('DELETE FROM event_photos WHERE event_id = ?')->execute([$eventId]);
$ins = $db->prepare('INSERT INTO event_photos (event_id, `key`, position) VALUES (?, ?, ?)');
$pos = 0;
foreach ($keys as $k) {
$k = trim((string)$k);
if ($k === '') {
continue;
}
$pos += 10;
$ins->execute([$eventId, $k, $pos]);
}
}
/** 删一张现场照片(连带删存储对象) */
public static function deletePhoto(int $familyId, int $eventId, int $photoId): void
{
$st = Database::get()->prepare('SELECT `key` FROM event_photos WHERE id = ? AND event_id = ?');
$st->execute([$photoId, $eventId]);
$key = (string)($st->fetchColumn() ?: '');
Database::get()->prepare('DELETE FROM event_photos WHERE id = ? AND event_id = ?')
->execute([$photoId, $eventId]);
if ($key !== '') {
Storage\Storage::delete($familyId, $key);
}
}
/** 删除整条观影记录(events 级联带走卫星/同行人/照片行;存储对象手动清) */
public static function destroy(int $familyId, int $id): void
{
$keys = [];
foreach (self::photos($id) as $p) {
$keys[] = (string)$p['key'];
}
$st = Database::get()->prepare(
'SELECT poster_key, ticket_key FROM movie_details WHERE event_id = ?'
);
$st->execute([$id]);
if ($row = $st->fetch()) {
foreach ([$row['poster_key'], $row['ticket_key']] as $k) {
if ($k) {
$keys[] = (string)$k;
}
}
}
Database::get()->prepare('DELETE FROM events WHERE id = ? AND family_id = ? AND kind = "movie"')
->execute([$id, $familyId]);
foreach ($keys as $k) {
Storage\Storage::delete($familyId, $k);
}
}
// ---------------- 展示格式化 ----------------
/**
* 观影时刻的人话展示:
* 只有日期(时间恰好 00:00:00 且无散场)→「2026年09月30日」;
* 有开场时刻 →「2026年09月30日 19:30:00」;有散场 → 追加「 – 21:45:12」。
* 两个都空 →「日期待补」。
*/
public static function formatWatch(?string $start, ?string $end): string
{
if ($start === null || $start === '') {
return '日期待补';
}
$ts = strtotime($start);
if ($ts === false) {
return '日期待补';
}
$out = date('Y年m月d日', $ts);
$midnight = substr((string)$start, 11) === '00:00:00';
if (!$midnight) {
$out .= ' ' . date('H:i:s', $ts);
}
if ($end !== null && $end !== '') {
$ets = strtotime($end);
if ($ets !== false) {
if (date('Y-m-d', $ets) !== date('Y-m-d', $ts)) {
$out = date('Y年m月d日', $ts) . ($midnight ? '' : ' ' . date('H:i:s', $ts))
. ' – ' . date('Y年m月d日 H:i:s', $ets);
} else {
$out .= ' – ' . date('H:i:s', $ets);
}
}
}
return $out;
}
// ---------------- 小工具 ----------------
/** 'Y-m-d H:i:s' 校验(秒级精确);空串/不合法回 NULL */
private static function dtOrNull(string $v): ?string
{
$v = trim($v);
if ($v === '' || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $v)) {
return null;
}
return $v;
}
private static function locOrNull(string $v): ?string
{
$v = mb_substr(trim($v), 0, 200, 'UTF-8');
return $v !== '' ? $v : null;
}
private static function textOrNull(string $v): ?string
{
$v = trim($v);
return $v !== '' ? $v : null;
}
/** 存储键只留安全字符(相对键,不带路径穿越),空串回 NULL */
private static function keyOrNull(string $v): ?string
{
$v = ltrim(trim($v), '/');
if ($v === '' || strpos($v, '..') !== false || strpos($v, "\0") !== false) {
return null;
}
return $v;
}
}