Files
FamilyBook/app/MovieStats.php
T
laoyang 7544fb9f6a v41 第 41 批:流水线提速 + 12 项界面/交互开发 + DAV 彻底修复
DAV(用户「务必彻底解决」):
- 真根因是 home-set 层级错:CalDav::HOME_PATH 原为 /dav/calendars/、
  CardDav 原为 /carddav/addressbooks/,而日历本/通讯录的 href 在
  /dav/N.ics/ 与 /carddav/N.vcf/ —— home-set 下面一本都没有。
  Thunderbird 按 home-set 前缀认成员,于是「找不到日历本 / 找不到联系人」。
  改为 /dav/ 与 /carddav/(所有既有 URL 一字不变,老配置不作废)。
- 又发现并修掉 Depth:0 违反 RFC 4918:原实现无论 depth 都列出全部子集合,
  且第一条 response 是日历本(不含 current-user-principal)。改为按 depth 分流,
  Depth:0 只回资源自身、Depth:1 才列出子集合。

界面与交互:
- 观影:新建影院弹窗层级修正 + 详细地址;照片左右按钮悬停不再位移;
  弹框底栏改「确定 / 取消」。
- 旅程:新建旅程改弹窗并按观影式排版;详情页四个动作按钮按用户指定顺序与配色;
  编辑与新增行程弹窗化(单容器 + AJAX);行程时间控件改 AntD(精确到分);
  参与人默认带上自己;车次号/车牌号紧跟交通方式词。
- 资源:新建/编辑改弹窗 + 观影式排版 + 新建时即定共享人。
- 纪念日:新建改弹框 + 观影式排版。

验证:
- e2e 1510/0;v41 十二项真机 48/0;DAV 反向验证 8/0;
  部署断言空跑 337/0;模板 div 深度三形态穷举 33 组全配平。
- 线上 DAV 终验(--resolve 真实入口 + 真 DAV 口令)12/0;
  线上确认 Depth:0 恰好 1 个 response。
2026-10-11 10:12:11 +08:00

453 lines
20 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();
}
/**
* 月份 / 星期的**图表配色**(v40)。
*
* 🔴 用户 2026-10-10:「观影统计里面,月份分布和星期分布,12 个月用 12 种不同的颜色,
* 星期 7 天用 7 天用 7 种不同的颜色。」
*
* ⚠️ 放在这里(而不是模板里各写一份)的理由:月份分布与星期分布分别出现在
* `report.php`(年度报告)与 `stats.php`(月度趋势)两处,颜色必须是**同一套**,
* 否则同一个月在两个页面是两种颜色 —— 那种不一致用户一眼就会看出来。
* ⚠️ 色板是**环绕色相**取的 12 档(红→橙→黄→绿→青→蓝→靛→紫→品红),
* 相邻两档明度接近、色相拉开,在宣纸底上都能看清;
* 没有用"彩虹高饱和"是因为那和大面积暖棕的品牌底色打架。
*/
public const MONTH_COLORS = [
1 => '#C0533F', '#D2703C', '#DFA13A', '#C7B23E',
'#8CB04A', '#5FA05C', '#3F9E85', '#3E8FA6',
'#4478B5', '#5B63B0', '#8455AC', '#B0548F',
];
/** 星期配色:日 → 六(7 档,与月份色板取同一条色相环上均匀间隔的 7 个点) */
public const WEEKDAY_COLORS = [
'日' => '#C0533F', '一' => '#D98A3C', '二' => '#C7B23E',
'三' => '#5FA05C', '四' => '#3E8FA6', '五' => '#4478B5',
'六' => '#8455AC',
];
/** 取某个月(1-12)的颜色;越界回退到品牌色,绝不返回空串(那会画成黑块)。 */
public static function monthColor(int $m): string
{
return self::MONTH_COLORS[$m] ?? '#A84A28';
}
/** 取某个星期('日'..'六')的颜色 */
public static function weekdayColor(string $w): string
{
return self::WEEKDAY_COLORS[$w] ?? '#A84A28';
}
/**
* 年份配色(v41 用户 2026-10-10):「按年统计,也用不同的颜色来显示。」
*
* 为什么不按"年份 % 12"去借 MONTH_COLORS:那样 **12 年一循环**,
* 一个 30 年的观影史会出现两遍同色,看着像"同一年"。
* ⇒ 这里按**年份序号**在色相环上均匀取点(黄金角步进),
* 相邻年份色相差得开、且**不随年份范围变化**(同一个年份永远是同一个颜色,
* 不会因为"库里多了几年"就换色 —— 那种不稳定比不好看更糟)。
*/
public static function yearColor(int $year): string
{
// 黄金角 ≈ 137.5°,让相邻年份在色相环上尽量分开。
// 用绝对年份参与运算 ⇒ 结果只取决于年份本身(稳定、可复现)。
$hue = fmod(($year * 137.508), 360.0);
if ($hue < 0) {
$hue += 360.0;
}
// 明度/饱和度固定在这一档:宣纸底上都能看清,也不与品牌暖棕打架。
return self::hslToHex($hue, 0.46, 0.47);
}
/** HSL → #rrggbb(只给 yearColor 用,避免引一整个颜色库) */
private static function hslToHex(float $h, float $s, float $l): string
{
$c = (1 - abs(2 * $l - 1)) * $s;
$x = $c * (1 - abs(fmod($h / 60.0, 2) - 1));
$m = $l - $c / 2;
if ($h < 60) { $r = $c; $g = $x; $b = 0.0; }
elseif ($h < 120) { $r = $x; $g = $c; $b = 0.0; }
elseif ($h < 180) { $r = 0.0; $g = $c; $b = $x; }
elseif ($h < 240) { $r = 0.0; $g = $x; $b = $c; }
elseif ($h < 300) { $r = $x; $g = 0.0; $b = $c; }
else { $r = $c; $g = 0.0; $b = $x; }
return sprintf('#%02x%02x%02x',
(int)round(($r + $m) * 255), (int)round(($g + $m) * 255), (int)round(($b + $m) * 255));
}
/** 按观影人:参与了多少场(带头像,供统计页画条;名称按「我叫他什么」显示)
*
* 🔴 v40(用户 2026-10-10):「观影统计里面,如果这个人在名册里面,但是他一部电影的
* 记录也没有,就不要在下面的这个按观影人统计里面出现了,太长了。」
* ⇒ **只列出真的参与过至少一场的人**(`HAVING cnt > 0`)。
*
* ⚠️ 为什么原来是 LEFT JOIN 全列:那是"按人统计"字面意义上的做法,也方便看出
* "谁一场都没看过"。但用户的实际痛点是**列表太长**(名册上千人时,
* 这个区块会被几百行"0 场"淹没,真正有数据的人反而找不到)。
* ⇒ 按用户要求过滤;"0 场"这件事本来也不是这个页面要回答的问题。
* ⚠️ `HAVING` 而不是 `WHERE cnt > 0`:`cnt` 是聚合结果,`WHERE` 阶段还不存在。
* 写成 `WHERE` 会被 MySQL 报 `Unknown column 'cnt'`。 */
public static function byPerson(int $familyId, int $viewerId = 0): array
{
$st = Database::get()->prepare(
"SELECT p.id, p.name, p.avatar, p.avatar_color, 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, p.avatar_color, n.nickname
HAVING cnt > 0
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;
}
}