Files
FamilyBook/app/MovieStats.php
T
laoyang e9b3b8666b v11: 家谱按户排布(夫妻并排/长幼排序/父母括号/姻亲);观影设置下拉收拢影院·搜索·统计·年度报告;观影人头像
家谱(用户第 2 点,重写模板与 Kinship::tree):
- 同代聚成「夫妻单元」(户):血亲当主位、配偶并排在其右,兄弟_姐妹按长幼(长子左)
- 血亲可达性判「姻亲」:只走 parent/child 边从「我」可达者为本家血亲,妻子的父母
  走不到 → 标「姻亲」并排到本代末尾(旧判据「上无父母且下无子女」对岳父母恒为假,是真 bug)
- 父母括号连线:横杆盖住父母列到其子女各列,竖线落父母列中心、每子女列一道下线
- 称呼按长幼定死:姐夫/妹夫、嫂子/弟媳、大/小舅子、大/小姨子、大伯子/小叔子、
  大姑子/小姑子、侄子/侄女、外甥/外甥女、堂/表
- 列宽地板 minmax(min-content,1fr):原 minmax(0,1fr) 在窄屏+户数多时把列压到卡片
  (96px)以下 → 同代卡片互相重叠;.tnode 加 flex:0 0 auto 防被 flex 压扁

观影(用户第 3~5 点):
- 顶栏删「影院」入口(影院是观影模块的子功能);观影列表右侧加「⚙ 设置」下拉,
  内含 影院管理 / 高级搜索 / 统计 / 年度报告(后三者从 MovieLog 迁入,
  app/MovieStats.php + 三个只读路由,MySQL 方言;图表全用纯 CSS,不引 CDN)
- 评价区删除「我」的长条徽标(被 flex column stretch 拉成整行宽的丑色带)
- 观影人弹窗列表与已选 chips 带头像;syncChips 点名取 .att-name/.att-av
  (旧写法取「第一个 span」,v11 起那是隐藏占位,chip 上会没名字)

验证:e2e 432/0、桌面几何 268/0、移动几何 464/0、交互几何 97/0、
静态空跑 199/0、反向验证 v11 十条 10抓/0漏。本轮无 DB 变更(Schema 仍 v10)。
移动几何抓出两个真溢出并已修:高级搜索页 select 长选项撑破 .field、
原生 date 输入撑破 .date-range(360/375px)。
2026-10-01 12:11:43 +08:00

362 lines
15 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): array
{
$st = Database::get()->prepare(
"SELECT p.id, p.name, p.avatar, 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 = ?
WHERE p.family_id = ? AND p.kind = 'member'
GROUP BY p.id, p.name, p.avatar
ORDER BY cnt DESC, p.id"
);
$st->execute([$familyId, $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): 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);
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
FROM event_attendees a JOIN persons p ON p.id = a.person_id
WHERE a.event_id = ? AND a.person_id <> ?
ORDER BY a.position, p.id"
);
foreach ($list as &$mv) {
$co->execute([(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;
}
}