diff --git a/app/Kinship.php b/app/Kinship.php index 3c95fda..4614e43 100644 --- a/app/Kinship.php +++ b/app/Kinship.php @@ -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 + */ + 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; } /** 关系类型的「英文键」——给前端画线用,不受中文称呼影响 */ diff --git a/app/MovieStats.php b/app/MovieStats.php new file mode 100644 index 0000000..03270d5 --- /dev/null +++ b/app/MovieStats.php @@ -0,0 +1,362 @@ +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; + } +} \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css index 31ba3fd..fb8a371 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -100,6 +100,16 @@ a:hover { text-decoration: underline; } border-radius: 0 0 8px 8px; color: var(--muted); } .menu-list .menu-exit:hover { color: var(--brand); } +/* ---- 观影页的「设置」下拉(v11):一个齿轮图标 +
菜单 ---- */ +.movie-set { position: relative; } +.movie-set > summary { + list-style: none; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; +} +.movie-set > summary::-webkit-details-marker { display: none; } +.movie-set[open] > summary { border-color: var(--brand); color: var(--brand); } +/* 观影设置菜单是顶栏菜单的复用:位置从右上角改成按钮下方左对齐 */ +.movie-set .menu-list { left: 0; right: auto; top: calc(100% + 8px); } +.movie-set .menu-list a.on { color: var(--brand); background: var(--line-2); } .link-quiet { color: var(--muted); font-size: 14px; } .link-quiet:hover { color: var(--brand); } @@ -224,7 +234,11 @@ a:hover { text-decoration: underline; } /* ---------------- 表单 ---------------- */ .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } -.field { display: flex; flex-direction: column; gap: 6px; } +/* ⚠️ min-width:0 是必需的(v11 实测踩到):.field 是 grid 项,默认 min-width:auto + 会被内部 的 min-width:auto 约 150px(带日历图标 + 占位数字)—— + 两个日期框 + 「至」在 360px 下会横向溢出(实测 scrollW 390 > clientW 360)。 + 给 0 并让它们等比收缩;这是继 .field(select)之后同一页的第二个溢出源。 */ +.date-range .input { flex: 1 1 0; min-width: 0; } +.results-head { + display: flex; align-items: baseline; justify-content: space-between; + gap: 10px; margin: 0 2px 12px; +} +.results-head h3 { margin: 0; font-size: 17px; } +.results-head .sub { color: var(--muted); font-size: 13px; } + +/* ---- 年度报告 ---- */ +.year-pick { display: inline-block; } +.report-grid { + display: grid; gap: 14px; margin-bottom: 16px; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); +} +.report-grid.two { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } +.report-card { + background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); + padding: 18px 20px; box-shadow: var(--shadow); +} +.report-card .n { font-size: 32px; font-weight: 700; color: var(--brand); line-height: 1.15; font-variant-numeric: tabular-nums; } +.report-card .l { font-size: 13px; color: var(--muted); } +.report-card .v { font-size: 19px; font-weight: 600; margin-top: 2px; } +.report-card .s { font-size: 12px; color: var(--faint); margin-top: 4px; } + +/* 月份竖柱图(纯 CSS) */ +.vgrid { + display: grid; grid-template-columns: repeat(12, 1fr); gap: 6px; + align-items: end; height: 160px; margin-top: 4px; +} +.vcol { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; gap: 3px; } +.vcol .vbar { + width: 68%; min-height: 2px; background: linear-gradient(var(--brand-2), var(--brand)); + border-radius: 5px 5px 2px 2px; transition: height .4s ease; +} +.vcol .vlab { font-size: 11px; color: var(--muted); } +.vcol .vnum { font-size: 11px; color: var(--faint); font-variant-numeric: tabular-nums; } + +.kw-cloud { display: flex; flex-wrap: wrap; gap: 8px; } +.kw-cloud .kw { + font-size: 14px; padding: 4px 12px; border-radius: 999px; + background: var(--line-2); color: var(--brand-ink); border: 1px solid var(--line); +} diff --git a/index.php b/index.php index e13d5e3..1cffca8 100644 --- a/index.php +++ b/index.php @@ -21,6 +21,7 @@ use App\Family; use App\Kinship; use App\Movie; use App\MovieReview; +use App\MovieStats; use App\Nickname; use App\Person; use App\PersonGroup; @@ -1714,7 +1715,7 @@ switch ($r) { 'title' => '影院管理', 'cinemas' => Cinema::all($familyId), 'editing' => $editing, - ], 'cinemas'); + ], 'movies'); break; case 'movies/cinemas_save': @@ -1757,6 +1758,69 @@ switch ($r) { redirect('movies/cinemas'); break; + // ---------------- 统计 / 高级搜索 / 年度报告(v11,从 MovieLog 迁入) ---------------- + // 三个页面都挂在观影模块「设置」按钮下(顶栏不再单独放影院/统计入口)。 + // 只读,不需要 requireWrite —— 访客也能看统计,与观影列表同一口径。 + + /** 统计仪表板 */ + case 'movies/stats': + $familyId = (int)Auth::familyId(); + View::render('movies/stats', [ + 'title' => '观影统计', + 'ov' => MovieStats::overview($familyId), + 'byCinema' => MovieStats::byCinema($familyId), + 'byPerson' => MovieStats::byPerson($familyId), + 'byMonth' => MovieStats::byMonth($familyId, 12), + 'byYear' => MovieStats::byYear($familyId), + ], 'movies'); + break; + + /** 高级搜索(组合条件;条件全空时不查,页面提示先设置条件) */ + case 'movies/search': + $familyId = (int)Auth::familyId(); + $p = [ + 'title' => trim((string)($_GET['title'] ?? '')), + 'cinema_id' => trim((string)($_GET['cinema_id'] ?? '')), + 'person_id' => trim((string)($_GET['person_id'] ?? '')), + 'date_from' => trim((string)($_GET['date_from'] ?? '')), + 'date_to' => trim((string)($_GET['date_to'] ?? '')), + ]; + $hasCond = false; + foreach ($p as $v) { + if ($v !== '' && $v !== '0') { + $hasCond = true; + break; + } + } + View::render('movies/search', [ + 'title' => '高级搜索', + 'cinemas' => Cinema::all($familyId), + 'persons' => Person::all($familyId, '', 'member', (int)Auth::personId()), + 'p' => $p, + 'hasCond' => $hasCond, + 'rows' => $hasCond ? MovieStats::search($familyId, $p) : [], + ], 'movies'); + break; + + /** 年度报告(以登录者为中心) */ + case 'movies/report': + $familyId = (int)Auth::familyId(); + $meId = (int)Auth::personId(); + $years = MovieStats::years($familyId); + $year = (int)($_GET['year'] ?? 0); + if ($year <= 0 || !in_array($year, $years, true)) { + $year = $years ? (int)$years[0] : (int)date('Y'); + } + $people = Person::map($familyId); + View::render('movies/report', [ + 'title' => $year . ' 年度观影报告', + 'year' => $year, + 'years' => $years ?: [$year], + 'stats' => MovieStats::annual($familyId, $meId, $year), + 'meName' => (string)($people[$meId]['name'] ?? '我'), + ], 'movies'); + break; + // ---------------- 评价与讨论(v9,MovieLog 同款) ---------------- // 评价人是「登录账号背后的人」,与同行观影人无关;访客只读(requireWrite)。 diff --git a/templates/kin/tree.php b/templates/kin/tree.php index 86505b9..3ef2f04 100644 --- a/templates/kin/tree.php +++ b/templates/kin/tree.php @@ -1,13 +1,26 @@ '子女辈', 2 => '孙辈', 3 => '曾孙辈']; return $down[$level] ?? '下 ' . $level . ' 代'; }; + +$levels = array_keys($gens); +// 整棵图的宽度取「最宽那一代」—— 所有代共用一个宽度,连线才能跟人对齐 +$maxUnits = 1; +foreach ($levels as $lv) { + $maxUnits = max($maxUnits, count($units[$lv] ?? [])); +} +// ⚠️ 列宽最小值必须是 min-content(**不能**是 0): +// v11 实测踩到 —— 最小值给 0 时列会被压到 96px 以下,而卡片是固定 96px 宽, +// 于是窄屏 + 户数多时同代卡片**互相重叠**(照片与名字糊成一团)。 +// minmax(min-content, 1fr) 给列一个「不小于内容」的地板:内容窄时照旧均分填满, +// 内容宽过版心时整棵树交给 .tree-inner 横向滚动(这正是家谱的设计意图)。 +$colStyle = 'grid-template-columns: repeat(' . (int)$maxUnits . ', minmax(min-content, 1fr));'; ?>

我的家谱

+ 同代按长幼从左到右 · 夫妻并排 · 括号表示谁是谁的父母
@@ -37,28 +64,100 @@ $genWord = function (int $level): string {
- $ids): ?> -
-
- +
+ $level): ?> + + +
+
+ + 户 +
+ +
+ +
+ $id): ?> + + + + + + + + + 待确认 + + + + 姻亲 + +
+ +
- -
- - - - + + [第一个子女列, 最后一个子女列] + foreach ($kids as $i => $ku) { + $pk = $ku['parent_key']; + if ($pk === null) { + continue; + } + if (!isset($ranges[$pk])) { + $ranges[$pk] = [$i, $i]; + } else { + $ranges[$pk][1] = $i; + } + } + // 父母单元key => 它在上一代里的列号(拿来决定竖线落点) + $pidx = []; + foreach (($units[$level] ?? []) as $i => $pu) { + $pidx[$pu['key']] = $i; + } + ?> +
+ $rg): ?> + '', 'spouse' => null]; + $who = $pn['head'] . ($pn['spouse'] !== null ? ' 夫妇' : ''); + $pCol = $pidx[$pk] ?? $rg[0]; + $lo = min($pCol, $rg[0]); + $hi = max($pCol, $rg[1]); + $span = $hi - $lo + 1; + // 竖线落在父母列的中心(在横杆这一段里的百分比位置) + $pct = ($pCol - $lo + 0.5) / $span * 100; + ?> +
+ +
+ + $ku): ?> +
+ +
+ + + +
+

+ 「姻亲」= 因为婚姻进到这张图里的人(如我妻子的父母),不是本家的血亲。 + 括号横跨的那几户,就是括号上方那一户的子女。 +

+ \ No newline at end of file diff --git a/templates/layout.php b/templates/layout.php index 44e4bd2..239cf5d 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -1,13 +1,14 @@ ['首页', 'home'], - 'kin' => ['家谱', 'kin'], - 'people' => ['名册', 'people'], - 'movies' => ['观影', 'movies'], - 'cinemas' => ['影院', 'movies/cinemas'], + 'home' => ['首页', 'home'], + 'kin' => ['家谱', 'kin'], + 'people' => ['名册', 'people'], + 'movies' => ['观影', 'movies'], ]; // ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的, // 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。 diff --git a/templates/movies/form.php b/templates/movies/form.php index 3421771..faa058a 100644 --- a/templates/movies/form.php +++ b/templates/movies/form.php @@ -97,12 +97,13 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees)); + 借 noscript 样式把弹窗平铺成普通勾选列表,提交语义完全不变。 + v11:chips 与弹窗列表都带上头像($p 来自 Person::roster,含 p.avatar)。 */ ?>
- +
@@ -122,7 +123,8 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees)); data-shown=""> > - + + @@ -233,12 +235,26 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees)); chips.innerHTML = ''; Array.prototype.forEach.call(list.querySelectorAll('.att-item input:checked'), function (box) { var item = box.closest('.att-item'); - var label = item.querySelector('span').textContent; + // ⚠️ 必须点名取 .att-name:v11 起 .att-item 里第一个元素是头像 , + // 早先靠「取第一个 span 当名字」碰巧成立,现在第一个 span 是隐藏的 + // 首字母占位,选完人 chip 上就没名字了。 + var nameEl = item.querySelector('.att-name'); + var label = nameEl ? nameEl.textContent : box.value; + var av = item.querySelector('.att-av'); var id = box.value; var chip = document.createElement('span'); chip.className = 'att-chip'; chip.setAttribute('data-id', id); - chip.textContent = label; + if (av) { + var im = document.createElement('img'); + im.src = av.getAttribute('src'); // 服务端已 e() 过,这里原样搬 + im.alt = ''; + chip.appendChild(im); + } + var nm = document.createElement('span'); + nm.className = 'att-name'; + nm.textContent = label; + chip.appendChild(nm); var x = document.createElement('button'); x.type = 'button'; x.className = 'att-x'; x.textContent = '×'; x.title = '移除'; x.setAttribute('data-id', id); diff --git a/templates/movies/index.php b/templates/movies/index.php index 5d0018d..c5f12a0 100644 --- a/templates/movies/index.php +++ b/templates/movies/index.php @@ -23,10 +23,22 @@ $fmtNum = static function ($v): string {
diff --git a/templates/movies/report.php b/templates/movies/report.php new file mode 100644 index 0000000..7812048 --- /dev/null +++ b/templates/movies/report.php @@ -0,0 +1,143 @@ + 0; +$fmtDate = static function (?string $d): string { + return ($d !== null && $d !== '') ? date('Y年n月j日', strtotime($d)) : '—'; +}; +?> +
+

年度观影报告

+ 这一年看了 场 +
+
+ + +
+ 返回观影 +
+
+ + +
+ 年还没有你的观影记录 + 换一个年份看看,或者先去记一场电影。 +
+ + +
+
观影场次
+
看电影的天数
+
月均场次
+
平均几天看一场
+
+ +
+
+
最常去的影院
+
+ 0): ?>
去了 次
+
+
+
一起看电影最多的人
+
+ 0): ?>
同场 次
+
+
+
同场人最多的一部
+
+ 0): ?>
人一起看
+
+
+
这一年从
+
到
+
个不同的日子走进电影院
+
+
+ +
+

月份分布 每个月看几场

+
+ $cnt): ?> +
+ + 月 + +
+ +
+
+ +
+

星期分布 哪天最常去

+
+ +
+ 周 + + +
+ +
+
+ + +
+

这一年常出现的词 从你的备注里数的

+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/templates/movies/search.php b/templates/movies/search.php new file mode 100644 index 0000000..65fb4bb --- /dev/null +++ b/templates/movies/search.php @@ -0,0 +1,107 @@ + +
+

高级搜索

+ 组合多个条件精确查找 + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + 至 + +
+
+
+
+ + 清空条件 +
+
+ + +
+ 先设置一个条件 + 填上片名、挑一家影院、指定某个人,或者框一段日期,再点「搜索」。 +
+ +
+

搜索结果

+ 共 部 +
+ +
+ 没有找到符合条件的电影 + 放宽一点条件再试。 +
+ + + + \ No newline at end of file diff --git a/templates/movies/show.php b/templates/movies/show.php index 904ef76..32aff4c 100644 --- a/templates/movies/show.php +++ b/templates/movies/show.php @@ -317,8 +317,11 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
+ - 我 /10 @@ -352,7 +355,6 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us - 我
- 我 0 ? round($v / $max * 100, 2) : 0.0; +}; +$maxCinema = 1; +foreach ($byCinema as $r) { $maxCinema = max($maxCinema, (int)$r['cnt']); } +$maxPerson = 1; +foreach ($byPerson as $r) { $maxPerson = max($maxPerson, (int)$r['cnt']); } +$maxMonth = 1; +foreach ($byMonth as $r) { $maxMonth = max($maxMonth, (int)$r['cnt']); } +$maxYear = 1; +foreach ($byYear as $r) { $maxYear = max($maxYear, (int)$r['cnt']); } +$wd = static function (?string $d): string { + return ($d !== null && $d !== '') ? date('Y年n月j日', strtotime($d)) : '—'; +}; +?> +
+

观影统计

+ 一家人的观影概览 + +
+ +
+
电影总数
+
影院数
+
观影人数
+
总观影人次
+
+ + +
+ 观影跨度 + ~ +
+ + +
+

按影院统计 电影数量

+ +
+ +
+ + + + + + +
+ +
+ +

还没有影院数据。到「影院管理」加几家影院,记观影时就能选了。

+ +
+ +
+
+

按观影人统计 参与场次

+ +
+ +
+ + + + + + +
+ +
+ +

还没有观影人数据。

+ +
+ +
+

按年统计 观影数量

+ +
+ +
+ 年 + + +
+ +
+ +

还没有年份数据。

+ +
+
+ +
+

月度趋势 最近 12 个月

+ +
+ +
+ + + +
+ +
+ +

还没有月度数据。

+ +
+ + \ No newline at end of file