用户原话(九条):
①「观影详情页面左上角的观影记录,应该和观影的列表页面里面保持一致」
②「观影详情页面的编辑,还有返回观影的按钮,应该和观影列表页面里面的记一场电影保持一致」
③「删除按钮就用红色背景颜色」
④「观影详情里面的编辑按钮应该也是弹出的,和新增是一样的,编辑完以后,页面动态更新」
⑤「影院管理部分,注意控制列的宽度…最右侧的操作、编辑和删除两个按钮,已经被挤成两行了,
尝试让编辑和删除排在一行,宽度给它留够」
⑥「编辑使用蓝色的背景,删除使用红色的背景」
⑦「高级搜索里面的日期范围,也使用我们最新的控件」
⑧「观影统计里面,如果这个人在名册里面,但是他一部电影的记录也没有,就不要在下面的这个
按观影人统计里面出现了,太长了」
⑨「观影统计里面,月份分布和星期分布,12 个月用 12 种不同的颜色,星期 7 天用 7 种不同的颜色」
🔴🔴 **本轮最重:修掉一个数据丢失级的真 bug**(v37 引入、**影响所有 `.dt-range` 页面**)
· 现象:任何带已存时间的编辑表单,只要 JS 跑起来,日期在用户**什么都没动**时
就被抹掉 ⇒ 提交后库里 `starts_at` 变 **NULL**。
· 根因:`assets/js/app.js` 的 `bindParts()` 首屏写成 `rebuildDays(false)` ——
那个参数是"保留已选日吗",传 false 会**清空"日"下拉**;紧接着的 `sync()`
看到 y/mo 有值而 d 空 ⇒ 算出 '' ⇒ **把隐藏的 `*_date` 写成空串**。
· 修:改成 `rebuildDays(true)`。
· ⚠️ 为什么一直没被发现:e2e 是**服务端**驱动的(直接 POST 字段、不经过 JS);
真机探针只验"控件可见可点"与"改值能写回",**没验"什么都不动时值还在不在"**。
⇒ 补了回归断言 + 反向验证(把 true 改回 false 必须红)。
· ⚠️ 影响面大于观影:**日程 / 任务 / 旅程**的起止同样走 `.dt-range`,
修在共用件等于顺手修了四个模块。
① 详情页头部与列表页统一:`<span class="sub">观影记录</span>` → **`<h1>观影记录</h1>` + sub=片名**。
②③ 详情页「编辑」「返回观影」用**裸 `.btn`**(= 列表页「记一场电影」的模块品牌实心);
「删除」用 `.btn-del`(**红色实心**,原来是淡红描边的 `.btn-danger`)。
⚠️ 用户那句"编辑用蓝色"是**影院管理**的(见 ⑥),详情页要的是"与列表页一致" ——
第一版理解错、把详情页编辑也刷成蓝色,**反向验证当场抓住**。
④ 详情页「编辑」改**弹框** + AJAX + 保存后**动态更新**:
· 内嵌**同一份** `form.php`;新增 `$mvModalForce`(`$isEdit` 也强制弹框壳)
与可显式传的 `$mvOpen`;新增 `View::renderPartial()` 供校验失败时把弹框整块重画回 JSON。
· 保存成功:`fetch` 跟 302 → **只换正文容器**(保留滚动位置),并重跑页内内联 JS。
· 🔴 实测踩到并解决的四件事:
1. 模板里裸类名 `Person::roster()` 会**致命**(全局命名空间)⇒ 页面从此中断输出、
且不报给用户 ⇒ 弹框整块消失。必须 `\App\Person::`。
2. `<button form="mvForm">`(表单外的提交按钮):点它真的提交,但挂在 `document` 上的
`submit` 监听**收不到事件**(实测 `XRW=(无)`、`resource_type=document`)⇒ 整页跳走、
红字看不到 ⇒ 主路改成 **click 委托**(preventDefault),`submit` 只兜回车。
3. `innerHTML` 换正文容器后要重跑内联 JS,但**绝不能重跑带 `src` 的外部脚本**
(app.js 等,会二次绑定);本段脚本自己要有**幂等闸门**。
4. flash 是**一次性**的(`take_flash()` 读完即清)⇒ 成功后**不要**再取一次页面,
直接用第一次的 `r.text()`(否则"保存成功却没有任何提示")。
⑤⑥ 影院管理:`.cn-table { table-layout: fixed }` + 操作列 **132px** +
`.cn-ops { flex-wrap: nowrap }` ⇒ 编辑/删除**并排一行**;
新增语义色 `--blue: #2F6FB0`,编辑=蓝实心、删除=红实心
(⚠️ 必须在**页头动作区那一层**也写一条:`.btn` 的特异性会盖过基类)。
⑦ 高级搜索的日期范围换 AntD:新增 `[data-antd-daterange]`(**纯日期**版,
不传 showTime),与观影"时刻"那对共用同一套按需加载 + 中文 locale 逻辑。
⑧ `MovieStats::byPerson` 加 `HAVING cnt > 0`(⚠️ 不能写 `WHERE cnt > 0`:
聚合结果在 WHERE 阶段还不存在)。
⑨ 新增 `MovieStats::MONTH_COLORS`(12 色)/ `WEEKDAY_COLORS`(7 色)+
`monthColor()` / `weekdayColor()`;**统计页"月度趋势"与年度报告"月份分布"同月同色**
(用户没要求,但不一致一眼就看得出)。
验证(全部真机 + 反向验证):
· _fb_b40_movie.py **41/0**(九条逐条 + 两条 🔴🔴 日期不丢);
· _fb_b40_falsify.py **10/0**(日期抹掉 / 编辑按钮配色 / AJAX 主路 三处各自证伪 + 复原回全绿);
· 全路由 e2e **1502/0**(顺带修掉一条**依赖夹具数据**的脆弱断言:
"统计页必须有 bar-chart" → 无数据时改判"要么有图表、要么有空态");
· 页头按钮探针 **60/0** + 反向 **3/3 全部抓到**(删除按钮换成 `.btn-del` 后,
判据与注入目标都已同步更新);v37/v38/v39 各套全绿;
日期控件真机 53/0 + 反向 7/0;观影照片 16/0 + 8/0;DAV 三套;部署静态断言空跑 —— 全绿。
· 线上 11 个关键文件 SHA256 与本地**逐字节一致**。
⚠️ 顺带:`show.php` 的 JS 注释里写了字面 `<script>` / `<main>`,绊倒了我自己"按标签
提取内联 JS 做语法检查"的脚本(**与 v37 那次 `<form>` 同类**)—— 已把注释里的
标签字面量全部改写。⇒ 通识:模板注释里不要出现字面标签名。
415 lines
18 KiB
PHP
415 lines
18 KiB
PHP
<?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';
|
||
}
|
||
|
||
/** 按观影人:参与了多少场(带头像,供统计页画条;名称按「我叫他什么」显示)
|
||
*
|
||
* 🔴 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, 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
|
||
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;
|
||
}
|
||
} |