Files
FamilyBook/app/MovieStats.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

365 lines
16 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
/**
* 观影统计 / 高级搜索 / 年度报告的聚合查询 —— v11 从 MovieLog 迁入。
*
* MovieLog 那三个页面(stats.php / search.php / annual_report.php)原本直接写 SQL,
* 而且用的是 **SQLite 方言**(strftime('%Y', ...))。迁进家书时做了两件事:
* 1. 方言换 MySQL:strftime('%Y', x) → YEAR(x),'%Y-%m' → DATE_FORMAT(x, '%Y-%m');
* LIMIT 的绑定值不能用 execute([$n])(非模拟预处理下会被当字符串,
* MySQL 直接报 `LIMIT '12'` 语法错),一律 (int) 内联。
* 2. 表结构换家书的统一事件原语:movies → events(kind='movie'),
* viewers → event_attendees,remark → note,watch_date → starts_at,
* cinema_id 挂在 movie_details 上。
*
* 「我」的口径:观影人是同行的人(event_attendees)。年度报告以**登录者**为中心,
* 只统计他参与过的场次 —— 与 MovieLog 的 self_person_id 一致。
*/
declare(strict_types=1);
namespace App;
use PDO;
final class MovieStats
{
/** 顶部四张总览卡 + 观影跨度 */
public static function overview(int $familyId): array
{
$db = Database::get();
$one = static function (string $sql, array $a = []) use ($db): string {
$st = $db->prepare($sql);
$st->execute($a);
$v = $st->fetchColumn();
return $v === false || $v === null ? '' : (string)$v;
};
$span = $db->prepare(
"SELECT MIN(e.starts_at) AS f, MAX(e.starts_at) AS l
FROM events e WHERE e.family_id = ? AND e.kind = 'movie' AND e.starts_at IS NOT NULL"
);
$span->execute([$familyId]);
$row = $span->fetch() ?: ['f' => null, 'l' => null];
return [
'movies' => (int)$one("SELECT COUNT(*) FROM events WHERE family_id = ? AND kind = 'movie'", [$familyId]),
'cinemas' => (int)$one('SELECT COUNT(*) FROM cinemas WHERE family_id = ?', [$familyId]),
'persons' => (int)$one(
"SELECT COUNT(DISTINCT a.person_id) FROM event_attendees a
JOIN events e ON e.id = a.event_id AND e.kind = 'movie'
WHERE e.family_id = ?", [$familyId]),
'viewership' => (int)$one(
"SELECT COUNT(*) FROM event_attendees a
JOIN events e ON e.id = a.event_id AND e.kind = 'movie'
WHERE e.family_id = ?", [$familyId]),
'first_date' => $row['f'] !== null ? substr((string)$row['f'], 0, 10) : null,
'last_date' => $row['l'] !== null ? substr((string)$row['l'], 0, 10) : null,
];
}
/** 按影院:每家看过多少部(0 部的也列出来,便于发现重复建的影院) */
public static function byCinema(int $familyId): array
{
$st = Database::get()->prepare(
"SELECT c.id, c.name, c.place, c.color, COUNT(e.id) AS cnt
FROM cinemas c
LEFT JOIN movie_details d ON d.cinema_id = c.id
LEFT JOIN events e ON e.id = d.event_id AND e.kind = 'movie' AND e.family_id = c.family_id
WHERE c.family_id = ?
GROUP BY c.id, c.name, c.place, c.color
ORDER BY cnt DESC, c.name"
);
$st->execute([$familyId]);
return $st->fetchAll();
}
/** 按观影人:参与了多少场(带头像,供统计页画条;名称按「我叫他什么」显示) */
public static function byPerson(int $familyId, int $viewerId = 0): array
{
$st = Database::get()->prepare(
"SELECT p.id, p.name, p.avatar, n.nickname AS my_nickname, COUNT(e.id) AS cnt
FROM persons p
LEFT JOIN event_attendees a ON a.person_id = p.id
LEFT JOIN events e ON e.id = a.event_id AND e.kind = 'movie' AND e.family_id = ?
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE p.family_id = ? AND p.kind = 'member'
GROUP BY p.id, p.name, p.avatar, n.nickname
ORDER BY cnt DESC, p.id"
);
$st->execute([$familyId, $viewerId, $familyId]);
return $st->fetchAll();
}
/** 最近 n 个月(只列有记录的月份,按时间倒序) */
public static function byMonth(int $familyId, int $limit = 12): array
{
// ⚠️ LIMIT 不能走绑定参数:非模拟预处理下 PDO 会把它当字符串送过去,
// MySQL 报 `LIMIT '12'` 语法错。已 (int) 转,无注入面。
$st = Database::get()->prepare(
"SELECT DATE_FORMAT(e.starts_at, '%Y-%m') AS ym, COUNT(*) AS cnt
FROM events e
WHERE e.family_id = ? AND e.kind = 'movie' AND e.starts_at IS NOT NULL
GROUP BY ym
ORDER BY ym DESC
LIMIT " . (int)$limit
);
$st->execute([$familyId]);
return $st->fetchAll();
}
/** 按年 */
public static function byYear(int $familyId): array
{
$st = Database::get()->prepare(
"SELECT YEAR(e.starts_at) AS y, COUNT(*) AS cnt
FROM events e
WHERE e.family_id = ? AND e.kind = 'movie' AND e.starts_at IS NOT NULL
GROUP BY y
ORDER BY y DESC"
);
$st->execute([$familyId]);
return $st->fetchAll();
}
/** 有观影记录的年份(倒序),年度报告的年份下拉用 */
public static function years(int $familyId): array
{
$st = Database::get()->prepare(
"SELECT DISTINCT YEAR(e.starts_at) AS y
FROM events e
WHERE e.family_id = ? AND e.kind = 'movie' AND e.starts_at IS NOT NULL
ORDER BY y DESC"
);
$st->execute([$familyId]);
return array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
}
/**
* 高级搜索:片名 / 影院 / 观影人 / 日期范围 组合过滤。
* 条件全空时返回 [](页面据此提示「先设置条件」)。
*/
public static function search(int $familyId, array $p, int $viewerId = 0): array
{
$title = trim((string)($p['title'] ?? ''));
$cid = (int)($p['cinema_id'] ?? 0);
$pid = (int)($p['person_id'] ?? 0);
$from = self::dateOrNull((string)($p['date_from'] ?? ''));
$to = self::dateOrNull((string)($p['date_to'] ?? ''));
if ($title === '' && $cid <= 0 && $pid <= 0 && $from === null && $to === null) {
return [];
}
$sql =
"SELECT e.id, e.title, e.starts_at, e.ends_at,
d.poster_key, c.name AS cinema_name, c.color AS cinema_color,
(SELECT GROUP_CONCAT(p2.name ORDER BY a2.position, p2.id SEPARATOR '、')
FROM event_attendees a2 JOIN persons p2 ON p2.id = a2.person_id
WHERE a2.event_id = e.id) AS attendee_names
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];
if ($title !== '') {
$sql .= ' AND e.title LIKE ?';
$bind[] = '%' . $title . '%';
}
if ($cid > 0) {
$sql .= ' AND d.cinema_id = ?';
$bind[] = $cid;
}
if ($pid > 0) {
$sql .= ' AND EXISTS (SELECT 1 FROM event_attendees a3
WHERE a3.event_id = e.id AND a3.person_id = ?)';
$bind[] = $pid;
}
if ($from !== null) {
$sql .= ' AND e.starts_at >= ?';
$bind[] = $from . ' 00:00:00';
}
if ($to !== null) {
$sql .= ' AND e.starts_at <= ?';
$bind[] = $to . ' 23:59:59';
}
$sql .= ' ORDER BY e.starts_at DESC, e.id DESC';
$st = Database::get()->prepare($sql);
$st->execute($bind);
$rows = $st->fetchAll();
MovieReview::attachStats($rows);
Movie::attachAttendees($rows, $viewerId);
return $rows;
}
/**
* 年度报告:以**登录者**为中心,只统计他参与过的场次。
* 结构与 MovieLog::getAnnualStats 一致 —— 页面模板按同样的键取用。
*/
public static function annual(int $familyId, int $personId, int $year): array
{
$db = Database::get();
$y = (int)$year;
$blank = [
'total' => 0, 'days' => 0,
'top_cinema' => '无', 'top_cinema_count' => 0,
'top_person' => '无', 'top_person_count' => 0,
'first_date' => null, 'last_date' => null,
'most_viewers_movie' => '无', 'most_viewers_count' => 0,
'month_distribution' => array_fill(1, 12, 0),
'weekday_distribution' => ['日' => 0, '一' => 0, '二' => 0, '三' => 0, '四' => 0, '五' => 0, '六' => 0],
'avg_per_month' => 0, 'avg_days_between' => 0,
'keywords' => [], 'recent_movies' => [], 'my_movies' => [], 'common_viewers' => [],
];
if ($personId <= 0) {
return $blank;
}
// 「我参与过的场次」这个子查询被下面反复用 —— 写成 EXISTS 而不是 IN 大列表
$mine = "EXISTS (SELECT 1 FROM event_attendees am
WHERE am.event_id = e.id AND am.person_id = ?)";
$base = "FROM events e WHERE e.family_id = ? AND e.kind = 'movie' AND YEAR(e.starts_at) = ? AND {$mine}";
$args = static fn(array $extra = []): array => array_merge([$familyId, $y, $personId], $extra);
$one = static function (string $sql, array $a) use ($db): string {
$st = $db->prepare($sql);
$st->execute($a);
$v = $st->fetchColumn();
return $v === false || $v === null ? '' : (string)$v;
};
$out = $blank;
$out['total'] = (int)$one("SELECT COUNT(*) {$base}", $args());
$out['days'] = (int)$one(
"SELECT COUNT(DISTINCT DATE(e.starts_at)) {$base} AND e.starts_at IS NOT NULL", $args());
// 最常去的影院
$st = $db->prepare(
"SELECT c.name AS n, COUNT(*) AS c
FROM events e
JOIN movie_details d ON d.event_id = e.id
JOIN cinemas c ON c.id = d.cinema_id
WHERE e.family_id = ? AND e.kind = 'movie' AND YEAR(e.starts_at) = ? AND {$mine}
GROUP BY c.id, c.name ORDER BY c DESC LIMIT 1"
);
$st->execute($args());
if ($r = $st->fetch()) {
$out['top_cinema'] = (string)$r['n'];
$out['top_cinema_count'] = (int)$r['c'];
}
// 一起看过最多场的人
$st = $db->prepare(
"SELECT p.id, p.name, p.avatar, COUNT(*) AS c
FROM event_attendees a
JOIN persons p ON p.id = a.person_id
WHERE a.event_id IN (
SELECT e2.id FROM events e2
WHERE e2.family_id = ? AND e2.kind = 'movie' AND YEAR(e2.starts_at) = ?
AND EXISTS (SELECT 1 FROM event_attendees am
WHERE am.event_id = e2.id AND am.person_id = ?))
AND a.person_id <> ?
GROUP BY p.id, p.name, p.avatar ORDER BY c DESC"
);
$st->execute([$familyId, $y, $personId, $personId]);
$out['common_viewers'] = $st->fetchAll();
if ($out['common_viewers']) {
$out['top_person'] = (string)$out['common_viewers'][0]['name'];
$out['top_person_count'] = (int)$out['common_viewers'][0]['c'];
}
// 时间跨度
$st = $db->prepare("SELECT MIN(e.starts_at) AS f, MAX(e.starts_at) AS l {$base} AND e.starts_at IS NOT NULL");
$st->execute($args());
if ($r = $st->fetch()) {
$out['first_date'] = $r['f'] !== null ? substr((string)$r['f'], 0, 10) : null;
$out['last_date'] = $r['l'] !== null ? substr((string)$r['l'], 0, 10) : null;
}
// 同场人最多的一部
$st = $db->prepare(
"SELECT e.title AS t, (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS c
{$base}
ORDER BY c DESC, e.id DESC LIMIT 1"
);
$st->execute($args());
if ($r = $st->fetch()) {
$out['most_viewers_movie'] = (string)$r['t'];
$out['most_viewers_count'] = (int)$r['c'];
}
// 月份 / 星期分布
$st = $db->prepare(
"SELECT MONTH(e.starts_at) AS m, COUNT(*) AS c {$base} AND e.starts_at IS NOT NULL GROUP BY m"
);
$st->execute($args());
foreach ($st->fetchAll() as $r) {
$out['month_distribution'][(int)$r['m']] = (int)$r['c'];
}
$st = $db->prepare(
"SELECT DAYOFWEEK(e.starts_at) AS w, COUNT(*) AS c {$base} AND e.starts_at IS NOT NULL GROUP BY w"
);
$st->execute($args());
$wmap = [1 => '日', 2 => '一', 3 => '二', 4 => '三', 5 => '四', 6 => '五', 7 => '六'];
foreach ($st->fetchAll() as $r) {
$k = $wmap[(int)$r['w']] ?? null;
if ($k !== null) {
$out['weekday_distribution'][$k] = (int)$r['c'];
}
}
// 频率
$out['avg_per_month'] = $out['total'] > 0 ? round($out['total'] / 12, 1) : 0;
$out['avg_days_between'] = $out['total'] > 0 ? round(365 / $out['total'], 1) : 0;
// 关键词(从备注里分词,取前 5)
$st = $db->prepare("SELECT e.note {$base} AND e.note IS NOT NULL AND e.note <> ''");
$st->execute($args());
$wc = [];
foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $text) {
$words = preg_split('/[\s,,。.!!??、;;::]+/u', (string)$text, -1, PREG_SPLIT_NO_EMPTY);
foreach ($words as $w) {
$w = trim($w);
if (mb_strlen($w, 'UTF-8') >= 2) {
$wc[$w] = ($wc[$w] ?? 0) + 1;
}
}
}
arsort($wc);
$out['keywords'] = array_slice(array_keys($wc), 0, 5);
// 最近 5 部(只要片名)
$st = $db->prepare("SELECT e.title {$base} ORDER BY e.starts_at DESC, e.id DESC LIMIT 5");
$st->execute($args());
$out['recent_movies'] = array_map('strval', $st->fetchAll(PDO::FETCH_COLUMN));
// 我的观影清单(含海报 + 同场的人)
$st = $db->prepare(
"SELECT e.id, e.title, e.starts_at, d.poster_key
FROM events e LEFT JOIN movie_details d ON d.event_id = e.id
WHERE e.family_id = ? AND e.kind = 'movie' AND YEAR(e.starts_at) = ? AND {$mine}
ORDER BY e.starts_at DESC, e.id DESC"
);
$st->execute($args());
$list = $st->fetchAll();
$co = $db->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 = ? AND a.person_id <> ?
ORDER BY a.position, p.id"
);
foreach ($list as &$mv) {
$co->execute([$personId, (int)$mv['id'], $personId]);
$mv['viewers'] = $co->fetchAll();
}
unset($mv);
$out['my_movies'] = $list;
return $out;
}
/** 'Y-m-d' 校验;不合法回 null */
private static function dateOrNull(string $v): ?string
{
$v = trim($v);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : null;
}
}