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)。
This commit is contained in:
2026-10-01 12:11:43 +08:00
parent 2c36335c3e
commit e9b3b8666b
12 changed files with 1494 additions and 103 deletions
+319 -40
View File
@@ -99,6 +99,34 @@ final class Kinship
return array_map('intval', $g['children'][$id] ?? []);
}
/**
* 「我」的血亲集合:只走 parent / child 边能到达的人(**不含配偶边**)。
*
* 用途:家谱页判断某一户是不是「姻亲」。定义就取本家语义 ——
* 我妻子的父母只走血缘边从「我」出发是走不到的,所以是姻亲;
* 我妻子本人也一样走不到(她是配偶,不是血亲)。
*
* ⚠️ 用 BFS 并带上 $seen 防环:数据脏了(互为父母、自己给自己当父母)时
* 不会死循环 —— 这类脏数据在手工录入姓名和关系时真的会进来。
*/
private static function bloodRelatives(array $g, int $me): array
{
$out = [$me => true];
$q = [$me];
while ($q) {
$id = array_shift($q);
$nbrs = array_merge(self::parentIds($g, $id), self::childIds($g, $id));
foreach ($nbrs as $n) {
$n = (int)$n;
if (!isset($out[$n])) {
$out[$n] = true;
$q[] = $n;
}
}
}
return array_map('intval', array_keys($out));
}
/**
* 祖父母那一级:往上走一步再取父母。
* 只认「我的父母」,因为只有相邻两代有边 —— 这正是「只存两代」的兑现处。
@@ -217,23 +245,94 @@ final class Kinship
}
}
// ---- 姻亲:配偶的兄弟姐妹 / 兄弟姐妹的配偶 ----
foreach (self::spouseIds($g, $me) as $sp) {
if (in_array($other, self::siblingIds($g, $sp), true)) {
return $isKnown ? ($isMale ? '大舅子/小舅子' : '大姨子/小姨子') : '姻亲';
}
}
// ---- 兄弟姐妹的子女:侄子/侄女(兄弟的孩子)· 外甥/外甥女(姐妹的孩子)----
//
// v11 新增。这里原来会一路落到兜底的「亲属」—— 家谱第二层里,
// 我兄弟姐妹的孩子就混在「亲属」里,用户明确要求「外甥、侄子按关系完全明确下来」。
// 判据是**孩子父母(我的兄弟姐妹)的性别**,不是孩子的性别:
// 兄弟的孩子 = 侄子/侄女;姐妹的孩子 = 外甥/外甥女。
// 与排行无关(这里不分长幼)。
foreach (self::siblingIds($g, $me) as $sib) {
if (in_array($other, self::spouseIds($g, $sib), true)) {
return $isKnown ? ($isMale ? '姐夫/妹夫' : '嫂子/弟媳') : '姻亲';
if (!in_array($other, self::childIds($g, $sib), true)) {
continue;
}
$sibGender = strtolower((string)($people[$sib]['gender'] ?? 'x'));
if ($sibGender === 'm') {
if (!$isKnown) {
return '侄辈';
}
return $isMale ? '侄子' : '侄女';
}
if ($sibGender === 'f') {
if (!$isKnown) {
return '外甥辈';
}
return $isMale ? '外甥' : '外甥女';
}
return '侄辈'; // 连兄弟姐妹的性别都没填:不瞎猜,给个中性的
}
// ---- 表亲堂亲:父母的兄弟姐妹的子女 ----
foreach (self::parentSiblings($g, $me) as $ps) {
if (in_array($other, self::childIds($g, $ps), true)) {
return '表亲';
// ---- 姻亲①:配偶的兄弟姐妹 ----
//
// v11 起**按长幼定死**,不再输出「大舅子/小舅子」这种斜杠并列(用户明确要求)。
// 长幼比较的对象是**我的配偶**(不是我自己):比配偶大 = 大X子/大X姨…
// 用哪一套词,取决于「我随谁叫」:
// · 我(男)随妻子叫 → 她的兄弟 大/小舅子、她的姐妹 大/小姨子
// · 我(女)随丈夫叫 → 他的兄弟 大/小叔子、他的姐妹 大/小姑子
// 我没填性别时退而看配偶性别推断(异性夫妻的常见情形);两个都不知道才给「姻亲」。
$myGender = strtolower((string)($people[$me]['gender'] ?? 'x'));
foreach (self::spouseIds($g, $me) as $sp) {
if (!in_array($other, self::siblingIds($g, $sp), true)) {
continue;
}
if (!$isKnown) {
return '姻亲';
}
$spGender = strtolower((string)($people[$sp]['gender'] ?? 'x'));
$older = self::compareRank($people, $other, $sp) < 0; // 比我的配偶年长
$byWife = ($myGender === 'm') || ($myGender === 'x' && $spGender === 'f');
$byHusband= ($myGender === 'f') || ($myGender === 'x' && $spGender === 'm');
if ($byWife) {
return $isMale ? ($older ? '大舅子' : '小舅子') : ($older ? '大姨子' : '小姨子');
}
if ($byHusband) {
return $isMale ? ($older ? '大伯子' : '小叔子') : ($older ? '大姑子' : '小姑子');
}
return '姻亲';
}
// ---- 姻亲②:兄弟姐妹的配偶(姐夫/妹夫 · 嫂子/弟媳)----
//
// v11 起按**我那个兄弟姐妹相对我的长幼**定死(用户原话:「既然知道了每一代的长幼顺序,
// 那么谁是姐姐,谁是妹妹是很明确的事情,是妹夫就是妹夫,是姐夫就是姐夫」)。
foreach (self::siblingIds($g, $me) as $sib) {
if (!in_array($other, self::spouseIds($g, $sib), true)) {
continue;
}
if (!$isKnown) {
return '姻亲';
}
$sibOlder = self::compareRank($people, $sib, $me) < 0; // 我的这个兄弟姐妹比我年长
if ($isMale) {
return $sibOlder ? '姐夫' : '妹夫';
}
return $sibOlder ? '嫂子' : '弟媳';
}
// ---- 堂亲 / 表亲:父母的兄弟姐妹的子女 ----
// v11 起把「堂」与「表」分开 —— 旧写法一律「表亲」,把父亲兄弟的孩子
// (同姓同宗,是**堂**兄弟姐妹)说成表亲,辈分上没错但宗族关系是错的。
foreach (self::parentSiblings($g, $me) as $ps) {
if (!in_array($other, self::childIds($g, $ps), true)) {
continue;
}
$psGender = strtolower((string)($people[$ps]['gender'] ?? 'x'));
// 只有「父亲这一侧的兄弟」的孩子才是堂亲
$isPaternalUncle = $psGender === 'm' && in_array($ps, self::siblingIds($g, $myParents['father'] ?? 0), true);
if ($isPaternalUncle) {
return $isKnown ? ($isMale ? '堂兄弟' : '堂姐妹') : '堂亲';
}
return $isKnown ? ($isMale ? '表兄弟' : '表姐妹') : '表亲';
}
return '亲属';
@@ -358,15 +457,22 @@ final class Kinship
*
* 返回结构:
* [
* 'generations' => [ -2 => [节点...], -1 => [...], 0 => [...], 1 => [...] ],
* 'nodes' => [ id => ['id','name','label','rel'][...] ],
* 'generations' => [ -2 => [id,...], -1 => [...], 0 => [...], 1 => [...] ],
* —— 同代的人,**夫妻相邻**(旧版模板 people/show 的迷你家谱仍按它渲染)
* 'units' => [ level => [ 夫妻单元, ... ] ],
* —— v11 家谱页按「夫妻单元」渲染。单元 = 这一代里的一户:
* ids[0] 是主位(血亲),ids[1] 是配偶,配偶排在家谱上主位的右手边。
* 单元带 parent_key —— 上一代哪个单元是它的父母,
* 家谱页据此画「谁是谁的父母与孩子」的括号连线。
* 'unit_names' => [ 单元key => ['head'=>名, 'spouse'=>名|null] ],连线标题用
* 'nodes' => [ id => ['id','name','label','rel','unit','is_head',...] ]
* 'gen' => [ id => 相对代数 ]
* ]
* 其中 label 是「我该怎么称呼他」,rel 是「他是我的谁」(用于画连接线)。
*/
public static function tree(array $g, int $me, array $people, int $up = 3, int $down = 3): array
{
$gen = []; // id => 相对我的代数(我 = 0,父母 = -1,子女 = +1)
$seen = [];
$gen = []; // id => 相对我的代数(我 = 0,父母 = -1,子女 = +1)
$seen = [];
$queue = [[$me, 0]];
$seen[$me] = true;
@@ -404,35 +510,208 @@ final class Kinship
}
$gen[$me] = 0;
$generations = [];
$nodes = [];
// 按代数分堆(只留名册里真的有的人)
$byGen = [];
foreach ($gen as $id => $level) {
if (!isset($people[$id])) {
continue;
}
$label = self::label($g, $me, $id, $people);
$nodes[$id] = [
'id' => $id,
'name' => (string)$people[$id]['name'],
'label' => $label,
'rel' => self::relationWord($g, $me, $id),
'avatar'=> (string)($people[$id]['avatar'] ?? ''),
'gender'=> (string)($people[$id]['gender'] ?? 'x'),
'ph' => (int)($people[$id]['is_placeholder'] ?? 0) === 1,
'person'=> $people[$id],
];
$generations[$level][] = $id;
$byGen[$level][] = (int)$id;
}
ksort($generations);
foreach ($generations as &$ids) {
// 同代内排序:先配偶,再按排行
usort($ids, function ($a, $b) use ($people) {
return strcmp(self::rankValue($people, $a), self::rankValue($people, $b));
});
}
unset($ids);
ksort($byGen);
return ['generations' => $generations, 'nodes' => $nodes, 'gen' => $gen];
// ---- 逐代建「夫妻单元」→ 按长幼排序 → 记录单元位置,供下一代定位父母 ----
$unitsByLevel = [];
$unitOrder = []; // 单元 key => 该代内的下标(第几个单元,从左到右)
$nodeUnit = []; // person id => 单元 key
foreach (array_keys($byGen) as $level) {
$units = self::buildUnits($g, $people, $byGen[$level], $gen);
foreach ($units as &$u) {
$u['key'] = $level . ':' . $u['head'];
foreach ($u['ids'] as $m) {
$nodeUnit[$m] = $u['key'];
}
// 上一代里哪一户是它的父母(优先父、其次母)—— 连线挂到这一户上
$u['parent_key'] = self::bracketParent($g, $gen, $u['head'], $nodeUnit);
$u['parent_idx'] = $u['parent_key'] !== null
? ($unitOrder[$u['parent_key']] ?? PHP_INT_MAX) : -1;
}
unset($u);
// 排序:先按「父母那一户从左到右的位置」(同一对父母的孩子会挨在一起,
// 家谱页才能用一个括号把它们连到父母那一户),再按长幼(长子左、次子右)。
usort($units, function ($a, $b) use ($people) {
if ($a['parent_idx'] !== $b['parent_idx']) {
return $a['parent_idx'] <=> $b['parent_idx'];
}
$c = strcmp(self::rankValue($people, $a['head']), self::rankValue($people, $b['head']));
return $c !== 0 ? $c : ($a['head'] <=> $b['head']);
});
foreach ($units as $i => $u) {
$unitOrder[$u['key']] = $i;
}
$unitsByLevel[$level] = $units;
}
// ---- 谁是「姻亲」(配偶那边漂在树上的旁支)----
//
// 判据:这一户**不是我的血亲**。血亲 = 只走 parent/child 边能从「我」走到的人
// (不含配偶边)—— 这正是「本家」的定义,也和模板图例那句话一一对应。
//
// ⚠️ 这里原来用的是「上无父母 且 下无子女单元」,对**我妻子的父母**恒为假:
// 岳父母在我的树里当然有子女(我妻子就在树上),于是永远标不上「姻亲」,
// 而模板注释里点名的例子恰好就是「我妻子的父母」。实测(_fb_probe_kin_units.py)
// 那一户真的没标、还排在血亲左边。改成血亲可达性之后语义才对得上。
$blood = self::bloodRelatives($g, $me);
foreach ($unitsByLevel as $level => &$units) {
foreach ($units as &$u) {
$u['inlaw'] = !in_array((int)$u['head'], $blood, true)
&& !($u['spouse'] !== null && in_array((int)$u['spouse'], $blood, true));
}
unset($u);
// 姻亲排到本代**末尾**:血亲从左到右是按长幼的,姻亲插在中间会把
// 「长子/次子」的读数打乱(岳父母排到我父亲左边,看着像我家长辈)。
// 用两趟分区而不是 asort —— asort 在键相同(都是 false)时会打乱相对顺序,
// 而这里血亲的相对顺序(即长幼)是必须原样保留的。
$bloodU = [];
$inlawU = [];
foreach ($units as $u) {
if ($u['inlaw']) {
$inlawU[] = $u;
} else {
$bloodU[] = $u;
}
}
$units = array_merge($bloodU, $inlawU);
foreach ($units as $i => $u) {
$unitOrder[$u['key']] = $i;
}
}
unset($units);
// ---- 摊平成渲染用的数据 ----
$generations = [];
$nodes = [];
$unitNames = [];
foreach ($unitsByLevel as $level => $units) {
$generations[$level] = [];
foreach ($units as $u) {
$hn = (string)($people[$u['head']]['name'] ?? '');
$sn = $u['spouse'] !== null ? (string)($people[$u['spouse']]['name'] ?? '') : null;
$unitNames[$u['key']] = ['head' => $hn, 'spouse' => $sn];
foreach ($u['ids'] as $k => $id) {
$generations[$level][] = $id;
$p = $people[$id];
$nodes[$id] = [
'id' => (int)$id,
'name' => (string)$p['name'],
'label' => self::label($g, $me, (int)$id, $people),
'rel' => self::relationWord($g, $me, (int)$id),
'avatar' => (string)($p['avatar'] ?? ''),
'gender' => (string)($p['gender'] ?? 'x'),
'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
'unit' => $u['key'],
'is_head' => $k === 0,
'person' => $p,
];
}
}
}
return [
'generations' => $generations,
'units' => $unitsByLevel,
'unit_names' => $unitNames,
'nodes' => $nodes,
'gen' => $gen,
];
}
/**
* 把同一代的人聚成「夫妻单元」:夫妻并排(男左女右),单身自成单元。
*
* 单元的 ids[0] 是**主位** —— 优先给「在树里往上还有父母的那一个」(血亲),
* 因为连线是从主位往上接到父母那一户的。都/都不连上一代时按男左女右;
* 同性或性别未知就退而让排行靠前的当主位。
*
* @param int[] $ids 同一代的人
* @return array<int,array{ids:int[],head:int,spouse:?int}>
*/
private static function buildUnits(array $g, array $people, array $ids, array $gen): array
{
$ids = array_values(array_unique(array_map('intval', $ids)));
$inGen = array_fill_keys($ids, true);
$used = [];
$units = [];
$hasUp = static function (int $id) use ($g, $gen): bool {
foreach (self::parentIds($g, $id) as $p) {
if (isset($gen[$p]) && (int)$gen[$p] === (int)$gen[$id] - 1) {
return true;
}
}
return false;
};
$isMale = static function (int $id) use ($people): bool {
return strtolower((string)($people[$id]['gender'] ?? 'x')) === 'm';
};
foreach ($ids as $id) {
if (isset($used[$id])) {
continue;
}
$used[$id] = true;
$spouse = null;
foreach (self::spouseIds($g, $id) as $sp) {
$sp = (int)$sp;
if (isset($inGen[$sp]) && !isset($used[$sp])) {
$spouse = $sp;
break;
}
}
$head = $id;
$tail = null;
if ($spouse !== null) {
$used[$spouse] = true;
$tail = $spouse;
$idUp = $hasUp($id);
$spUp = $hasUp($spouse);
if ($spUp && !$idUp) {
$head = $spouse;
$tail = $id;
} elseif ($idUp === $spUp) {
if ($isMale($spouse) !== $isMale($id)) {
if ($isMale($spouse)) { // 男左女右
$head = $spouse;
$tail = $id;
}
} elseif (self::compareRank($people, $spouse, $id) < 0) {
$head = $spouse; // 同性/未知:排行靠前的当主位
$tail = $id;
}
}
}
$unitIds = [$head];
if ($tail !== null) {
$unitIds[] = $tail;
}
$units[] = ['ids' => $unitIds, 'head' => $head, 'spouse' => $tail];
}
return $units;
}
/** 这个人的父母落在上一代的哪个单元里(优先父、其次母);没有就 null */
private static function bracketParent(array $g, array $gen, int $head, array $nodeUnit): ?string
{
$level = (int)($gen[$head] ?? 0) - 1;
foreach (self::parents($g, $head) as $pid) {
if ($pid !== null
&& isset($nodeUnit[$pid])
&& (int)($gen[$pid] ?? 999) === $level) {
return $nodeUnit[$pid];
}
}
return null;
}
/** 关系类型的「英文键」——给前端画线用,不受中文称呼影响 */
+362
View File
@@ -0,0 +1,362 @@
<?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;
}
}