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:
+317
-38
@@ -99,6 +99,34 @@ final class Kinship
|
|||||||
return array_map('intval', $g['children'][$id] ?? []);
|
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)) {
|
// v11 新增。这里原来会一路落到兜底的「亲属」—— 家谱第二层里,
|
||||||
return $isKnown ? ($isMale ? '大舅子/小舅子' : '大姨子/小姨子') : '姻亲';
|
// 我兄弟姐妹的孩子就混在「亲属」里,用户明确要求「外甥、侄子按关系完全明确下来」。
|
||||||
}
|
// 判据是**孩子父母(我的兄弟姐妹)的性别**,不是孩子的性别:
|
||||||
}
|
// 兄弟的孩子 = 侄子/侄女;姐妹的孩子 = 外甥/外甥女。
|
||||||
|
// 与排行无关(这里不分长幼)。
|
||||||
foreach (self::siblingIds($g, $me) as $sib) {
|
foreach (self::siblingIds($g, $me) as $sib) {
|
||||||
if (in_array($other, self::spouseIds($g, $sib), true)) {
|
if (!in_array($other, self::childIds($g, $sib), true)) {
|
||||||
return $isKnown ? ($isMale ? '姐夫/妹夫' : '嫂子/弟媳') : '姻亲';
|
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)) {
|
// v11 起**按长幼定死**,不再输出「大舅子/小舅子」这种斜杠并列(用户明确要求)。
|
||||||
return '表亲';
|
// 长幼比较的对象是**我的配偶**(不是我自己):比配偶大 = 大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 '亲属';
|
return '亲属';
|
||||||
@@ -358,10 +457,17 @@ final class Kinship
|
|||||||
*
|
*
|
||||||
* 返回结构:
|
* 返回结构:
|
||||||
* [
|
* [
|
||||||
* 'generations' => [ -2 => [节点...], -1 => [...], 0 => [...], 1 => [...] ],
|
* 'generations' => [ -2 => [id,...], -1 => [...], 0 => [...], 1 => [...] ],
|
||||||
* 'nodes' => [ id => ['id','name','label','rel'][...] ],
|
* —— 同代的人,**夫妻相邻**(旧版模板 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
|
public static function tree(array $g, int $me, array $people, int $up = 3, int $down = 3): array
|
||||||
{
|
{
|
||||||
@@ -404,35 +510,208 @@ final class Kinship
|
|||||||
}
|
}
|
||||||
$gen[$me] = 0;
|
$gen[$me] = 0;
|
||||||
|
|
||||||
$generations = [];
|
// 按代数分堆(只留名册里真的有的人)
|
||||||
$nodes = [];
|
$byGen = [];
|
||||||
foreach ($gen as $id => $level) {
|
foreach ($gen as $id => $level) {
|
||||||
if (!isset($people[$id])) {
|
if (!isset($people[$id])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$label = self::label($g, $me, $id, $people);
|
$byGen[$level][] = (int)$id;
|
||||||
$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;
|
|
||||||
}
|
}
|
||||||
ksort($generations);
|
ksort($byGen);
|
||||||
foreach ($generations as &$ids) {
|
|
||||||
// 同代内排序:先配偶,再按排行
|
|
||||||
usort($ids, function ($a, $b) use ($people) {
|
|
||||||
return strcmp(self::rankValue($people, $a), self::rankValue($people, $b));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
unset($ids);
|
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 关系类型的「英文键」——给前端画线用,不受中文称呼影响 */
|
/** 关系类型的「英文键」——给前端画线用,不受中文称呼影响 */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+202
-20
@@ -100,6 +100,16 @@ a:hover { text-decoration: underline; }
|
|||||||
border-radius: 0 0 8px 8px; color: var(--muted);
|
border-radius: 0 0 8px 8px; color: var(--muted);
|
||||||
}
|
}
|
||||||
.menu-list .menu-exit:hover { color: var(--brand); }
|
.menu-list .menu-exit:hover { color: var(--brand); }
|
||||||
|
/* ---- 观影页的「设置」下拉(v11):一个齿轮图标 + <details> 菜单 ---- */
|
||||||
|
.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 { color: var(--muted); font-size: 14px; }
|
||||||
.link-quiet:hover { color: var(--brand); }
|
.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; }
|
.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
|
||||||
|
会被内部 <select> 的**最长选项文字**撑开 —— 高级搜索页在 360/375px 下整页横向溢出
|
||||||
|
(scrollW 390 > clientW 360),而「影院」下拉正是长选项。给了 0 之后列才能收进版心,
|
||||||
|
下拉自身由 width:100% 跟着收。与 .hero-main / .person-move .select 同一套做法。 */
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||||
.field.full { grid-column: 1 / -1; }
|
.field.full { grid-column: 1 / -1; }
|
||||||
.field label { font-size: 13px; color: var(--muted); font-weight: 500; }
|
.field label { font-size: 13px; color: var(--muted); font-weight: 500; }
|
||||||
.field .tip { font-size: 12px; color: var(--muted); }
|
.field .tip { font-size: 12px; color: var(--muted); }
|
||||||
@@ -366,9 +380,20 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 家谱(以我为中心) ---------------- */
|
/* ---------------- 家谱(以我为中心) ----------------
|
||||||
|
v11 改版:每代是「夫妻单元」网格 + 父母括号连线。
|
||||||
|
所有代的列网格共用同一份 template(宽度取最宽那一代),连线才能跟人对齐。 */
|
||||||
.tree { display: flex; flex-direction: column; align-items: center; gap: 0; }
|
.tree { display: flex; flex-direction: column; align-items: center; gap: 0; }
|
||||||
.tree-gen { width: 100%; }
|
.tree-inner {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto; /* 家里人再多也不挤:横向滚,不改版心 */
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
/* ⚠️ 必须是 min-content 而不是 100%:min-content 让「代」按内容自然展开,
|
||||||
|
.tree-inner 才有东西可滚;列的地板由 .tree-row 的 minmax(min-content,1fr) 兜。
|
||||||
|
两处一起才成立 —— 单改一处会在窄屏下把卡片压重叠(v11 实测踩到)。 */
|
||||||
|
.tree-gen { min-width: min-content; }
|
||||||
.tree-gen-label {
|
.tree-gen-label {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
margin: 0 0 8px; padding-left: 2px;
|
margin: 0 0 8px; padding-left: 2px;
|
||||||
@@ -378,13 +403,28 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
font-size: 11px; color: var(--muted); background: var(--line-2);
|
font-size: 11px; color: var(--muted); background: var(--line-2);
|
||||||
padding: 1px 8px; border-radius: 999px;
|
padding: 1px 8px; border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
/* 一代 = 若干「户」,每户占网格的一列(列数由 inline grid-template-columns 给) */
|
||||||
.tree-row {
|
.tree-row {
|
||||||
display: flex; flex-wrap: wrap; gap: 10px;
|
display: grid; gap: 14px;
|
||||||
justify-content: center; align-items: stretch;
|
align-items: start;
|
||||||
|
}
|
||||||
|
.tree-unit {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 0;
|
||||||
|
position: relative; padding: 0 2px;
|
||||||
|
}
|
||||||
|
/* 夫妻之间的短横(儿女亲家那种「连着的两个人」) */
|
||||||
|
.tree-bond { width: 14px; flex: 0 0 auto; display: flex; align-items: center; }
|
||||||
|
.tree-bond i {
|
||||||
|
display: block; width: 100%; height: 2px; border-radius: 2px;
|
||||||
|
background: var(--line); transform: translateY(2px);
|
||||||
}
|
}
|
||||||
.tnode {
|
.tnode {
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 5px;
|
display: flex; flex-direction: column; align-items: center; gap: 5px;
|
||||||
width: 104px; padding: 12px 8px 10px; text-align: center;
|
/* ⚠️ flex:0 0 auto 是必需的(v11 实测踩到):.tnode 是 .tree-unit(flex)的子项,
|
||||||
|
默认 flex-shrink:1 会让卡片**先被压**,列地板的 min-content 也跟着变小 ——
|
||||||
|
等于把「列不许压到卡片以下」这条设计从内部掏空了。卡宽由下面的 width 定,
|
||||||
|
窄屏另有 78px 的媒体查询(那是刻意的窄版式,不是压缩)。 */
|
||||||
|
flex: 0 0 auto; width: 96px; padding: 12px 6px 10px; text-align: center;
|
||||||
background: var(--surface); border: 1px solid var(--line);
|
background: var(--surface); border: 1px solid var(--line);
|
||||||
border-radius: 14px; box-shadow: var(--shadow);
|
border-radius: 14px; box-shadow: var(--shadow);
|
||||||
color: var(--ink); transition: transform .15s ease, box-shadow .15s ease;
|
color: var(--ink); transition: transform .15s ease, box-shadow .15s ease;
|
||||||
@@ -401,11 +441,33 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
}
|
}
|
||||||
.tnode.is-me .tlabel { color: var(--brand-ink); font-weight: 600; }
|
.tnode.is-me .tlabel { color: var(--brand-ink); font-weight: 600; }
|
||||||
.tnode.is-placeholder { border-style: dashed; }
|
.tnode.is-placeholder { border-style: dashed; }
|
||||||
/* 世代之间的连接线(纯装饰,说明"往下走一代") */
|
/* 因婚姻进图、上下都不接血的旁支(如妻子的父母):虚线边框 + 角标,免得像断线 */
|
||||||
.tree-link {
|
.tnode.is-inlaw { border-style: dashed; background: #FCFAF6; }
|
||||||
width: 2px; height: 22px; background: linear-gradient(var(--line), var(--line-2));
|
.tree-inlaw-tag {
|
||||||
margin: 6px auto;
|
position: absolute; top: -8px; right: 0;
|
||||||
|
font-size: 10px; color: var(--faint); background: var(--paper);
|
||||||
|
border: 1px solid var(--line); border-radius: 999px; padding: 0 6px;
|
||||||
}
|
}
|
||||||
|
/* ---- 父母括号连线 ----
|
||||||
|
.tc-bar = 横杆(占父母列 + 子女各列,居中画一根竖线上去接父母)
|
||||||
|
.tc-down = 每个子女列的一道竖线(接进同一个横杆)*/
|
||||||
|
.tree-conn {
|
||||||
|
display: grid; gap: 14px; height: 30px; position: relative;
|
||||||
|
}
|
||||||
|
.tc-bar { position: relative; align-self: end; border-top: 0; }
|
||||||
|
.tc-bar::before {
|
||||||
|
content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
|
||||||
|
background: var(--line); border-radius: 2px;
|
||||||
|
}
|
||||||
|
.tc-up {
|
||||||
|
position: absolute; bottom: 0; width: 2px; height: 14px;
|
||||||
|
background: var(--line); transform: translateX(-1px);
|
||||||
|
}
|
||||||
|
.tc-down {
|
||||||
|
align-self: end; justify-self: center;
|
||||||
|
width: 2px; height: 30px; background: var(--line);
|
||||||
|
}
|
||||||
|
.tree-legend { font-size: 12px; color: var(--muted); margin: 14px 2px 0; }
|
||||||
|
|
||||||
/* 详情页里的迷你家谱 */
|
/* 详情页里的迷你家谱 */
|
||||||
.mini-tree { display: flex; flex-direction: column; gap: 8px; }
|
.mini-tree { display: flex; flex-direction: column; gap: 8px; }
|
||||||
@@ -473,9 +535,12 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.rel-form { grid-template-columns: 1fr; }
|
.rel-form { grid-template-columns: 1fr; }
|
||||||
.tnode { width: 88px; padding: 9px 6px 8px; }
|
.tnode { width: 78px; padding: 8px 4px 7px; }
|
||||||
.tnode .tph { width: 44px; height: 44px; }
|
.tnode .tph { width: 40px; height: 40px; }
|
||||||
.tree-row { gap: 8px; }
|
.tree-row { gap: 8px; }
|
||||||
|
.tree-unit { padding: 0; }
|
||||||
|
.tree-bond { width: 8px; }
|
||||||
|
.tree-conn { gap: 8px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -977,14 +1042,18 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
.stars-disp { display: inline-flex; gap: 1px; font-size: 17px; line-height: 1; vertical-align: -2px; }
|
.stars-disp { display: inline-flex; gap: 1px; font-size: 17px; line-height: 1; vertical-align: -2px; }
|
||||||
.stars-disp .d-on { color: #f5b301; }
|
.stars-disp .d-on { color: #f5b301; }
|
||||||
.stars-disp .d-off { color: #d3d7de; }
|
.stars-disp .d-off { color: #d3d7de; }
|
||||||
/* ---- 同行观影人选择(v10):chips + 弹窗 + 拼音首字母联想 ---- */
|
/* ---- 同行观影人选择(v10):chips + 弹窗 + 拼音首字母联想(v11 起带头像) ---- */
|
||||||
.att-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
.att-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||||
.att-chips:empty { display: none; }
|
.att-chips:empty { display: none; }
|
||||||
.att-chip {
|
.att-chip {
|
||||||
display: inline-flex; align-items: center; gap: 6px; padding: 3px 6px 3px 10px;
|
display: inline-flex; align-items: center; gap: 6px; padding: 3px 6px 3px 4px;
|
||||||
border: 1px solid var(--line); border-radius: 999px; background: var(--surface);
|
border: 1px solid var(--line); border-radius: 999px; background: var(--surface);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.att-chip img {
|
||||||
|
width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
|
||||||
|
background: #F1E7D6;
|
||||||
|
}
|
||||||
.att-chip .att-x {
|
.att-chip .att-x {
|
||||||
border: 0; background: none; color: var(--muted); font-size: 15px;
|
border: 0; background: none; color: var(--muted); font-size: 15px;
|
||||||
line-height: 1; padding: 0 2px; cursor: pointer;
|
line-height: 1; padding: 0 2px; cursor: pointer;
|
||||||
@@ -1013,7 +1082,12 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
}
|
}
|
||||||
.att-item:last-child { border-bottom: 0; }
|
.att-item:last-child { border-bottom: 0; }
|
||||||
.att-item:hover { background: var(--line-2); }
|
.att-item:hover { background: var(--line-2); }
|
||||||
.att-item input { accent-color: var(--brand); }
|
.att-item input { accent-color: var(--brand); flex: 0 0 auto; }
|
||||||
|
.att-item .att-av {
|
||||||
|
width: 30px; height: 30px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
|
||||||
|
background: #F1E7D6;
|
||||||
|
}
|
||||||
|
.att-item .att-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.att-item .att-initials { margin-left: auto; color: var(--muted); font-size: 12px; }
|
.att-item .att-initials { margin-left: auto; color: var(--muted); font-size: 12px; }
|
||||||
.att-empty { padding: 10px 12px; color: var(--muted); font-size: 13px; }
|
.att-empty { padding: 10px 12px; color: var(--muted); font-size: 13px; }
|
||||||
/* ---- 豆瓣自动补充(v10):片名行 + 下拉候选 + 海报预览 ---- */
|
/* ---- 豆瓣自动补充(v10):片名行 + 下拉候选 + 海报预览 ---- */
|
||||||
@@ -1081,11 +1155,10 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
}
|
}
|
||||||
.rv-who { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
.rv-who { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
.rv-name { font-size: 14px; font-weight: 600; color: var(--ink); }
|
.rv-name { font-size: 14px; font-weight: 600; color: var(--ink); }
|
||||||
.rv-mine {
|
/* ⚠️ 写着「我」的那个徽标(类名是「评价」区块 + 「我的」后缀)v11 删掉了:
|
||||||
display: inline-block; vertical-align: middle; margin-left: 6px;
|
它在 .rv-who(flex column)里被 align-items:stretch 拉成整行宽的色带,
|
||||||
font-size: 11px; line-height: 16px; background: var(--brand);
|
星星旁边显出一条丑长的品牌色条。删除按钮已经表达了「这是我写的」,
|
||||||
color: #fff; border-radius: 6px; padding: 0 6px;
|
不需要徽标。别再把它加回来。 */
|
||||||
}
|
|
||||||
.rv-time { display: block; font-size: 12px; color: var(--muted); }
|
.rv-time { display: block; font-size: 12px; color: var(--muted); }
|
||||||
.rv-score { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 5px; }
|
.rv-score { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 5px; }
|
||||||
.rv-score .stars-disp { font-size: 16px; }
|
.rv-score .stars-disp { font-size: 16px; }
|
||||||
@@ -1177,3 +1250,112 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
.lb-next { right: 6px; }
|
.lb-next { right: 6px; }
|
||||||
.lb-img { max-width: 88vw; max-height: 70vh; }
|
.lb-img { max-width: 88vw; max-height: 70vh; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
v11:观影统计 / 高级搜索 / 年度报告(从 MovieLog 迁入)
|
||||||
|
版式沿用家书的暖纸配色;图表全用 CSS(不引 CDN 图表库)。
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---- 统计仪表板 ---- */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid; gap: 14px; margin-bottom: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
||||||
|
padding: 16px 18px; text-align: center; box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.stat-card .number { font-size: 30px; font-weight: 700; color: var(--brand); line-height: 1.2; font-variant-numeric: tabular-nums; }
|
||||||
|
.stat-card .label { font-size: 13px; color: var(--muted); margin-top: 2px; }
|
||||||
|
.stats-span {
|
||||||
|
display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.stats-span .label { font-size: 13px; color: var(--muted); }
|
||||||
|
.stats-span .value { font-size: 16px; font-weight: 600; }
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
||||||
|
padding: 18px 22px; box-shadow: var(--shadow); margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.chart-card h3 { font-size: 16px; font-weight: 600; margin: 0 0 14px; display: flex; align-items: baseline; gap: 8px; }
|
||||||
|
.chart-card h3 .hint { font-size: 12px; color: var(--faint); font-weight: 400; }
|
||||||
|
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
@media (max-width: 720px) { .two-col { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.bar-chart { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.bar-item { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.bar-item .bar-label {
|
||||||
|
width: 150px; flex: 0 0 auto; font-size: 13px; color: var(--ink);
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.bar-item .bar-label .bar-sub { color: var(--faint); font-size: 11px; }
|
||||||
|
.bar-item .bar-avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; }
|
||||||
|
.bar-item .color-dot { display: inline-block; width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; }
|
||||||
|
.bar-item .bar-track {
|
||||||
|
flex: 1; height: 20px; background: var(--line-2); border-radius: 6px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar-item .bar-fill {
|
||||||
|
display: block; height: 100%; border-radius: 6px; background: var(--brand);
|
||||||
|
}
|
||||||
|
.bar-item .bar-fill-alt { background: var(--brand-2); }
|
||||||
|
.bar-item .bar-fill-green { background: var(--green); }
|
||||||
|
.bar-item .bar-count {
|
||||||
|
width: 40px; flex: 0 0 auto; text-align: right; font-size: 13px; font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.bar-item .bar-label { width: 92px; font-size: 12px; }
|
||||||
|
.bar-item .bar-count { width: 30px; }
|
||||||
|
.chart-card { padding: 14px 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 高级搜索 ---- */
|
||||||
|
.adv-form { margin-bottom: 18px; }
|
||||||
|
.date-range { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.date-range .to { color: var(--muted); font-size: 13px; flex: 0 0 auto; }
|
||||||
|
/* ⚠️ 原生 <input type="date"> 的 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use App\Family;
|
|||||||
use App\Kinship;
|
use App\Kinship;
|
||||||
use App\Movie;
|
use App\Movie;
|
||||||
use App\MovieReview;
|
use App\MovieReview;
|
||||||
|
use App\MovieStats;
|
||||||
use App\Nickname;
|
use App\Nickname;
|
||||||
use App\Person;
|
use App\Person;
|
||||||
use App\PersonGroup;
|
use App\PersonGroup;
|
||||||
@@ -1714,7 +1715,7 @@ switch ($r) {
|
|||||||
'title' => '影院管理',
|
'title' => '影院管理',
|
||||||
'cinemas' => Cinema::all($familyId),
|
'cinemas' => Cinema::all($familyId),
|
||||||
'editing' => $editing,
|
'editing' => $editing,
|
||||||
], 'cinemas');
|
], 'movies');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'movies/cinemas_save':
|
case 'movies/cinemas_save':
|
||||||
@@ -1757,6 +1758,69 @@ switch ($r) {
|
|||||||
redirect('movies/cinemas');
|
redirect('movies/cinemas');
|
||||||
break;
|
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 同款) ----------------
|
// ---------------- 评价与讨论(v9,MovieLog 同款) ----------------
|
||||||
// 评价人是「登录账号背后的人」,与同行观影人无关;访客只读(requireWrite)。
|
// 评价人是「登录账号背后的人」,与同行观影人无关;访客只读(requireWrite)。
|
||||||
|
|
||||||
|
|||||||
+110
-11
@@ -1,11 +1,24 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 我的家谱:以我为中心,向上向下各三代。
|
* 我的家谱:以我为中心,向上向下各三代。**v11 重写** —— 从「按代平铺」改成「按户排布」。
|
||||||
*
|
*
|
||||||
* 只做「呈现 + 说清楚推导规则」两件事 —— 关系的录入在成员详情页,
|
* 用户提出的四点,对应这里的四个做法:
|
||||||
* 因为那里有具体的人,比在一张图上点来点去更好理解。
|
* ① 「每一层级都需要按照长子左、次子右的顺序排序,夫妻要放在一起」
|
||||||
|
* → 同一代不再平铺一堆卡片,而是先聚成**夫妻单元**(一户),
|
||||||
|
* 夫妻并排(男左女右、血亲在左),再把单元按长幼从左到右排。
|
||||||
|
* 聚合与排序在 App\Kinship::tree() 里算好(['units']),这里只负责画。
|
||||||
|
* ② 「上下代之间要有连线,明确谁是谁的父母与孩子」
|
||||||
|
* → 用**父母括号**(family bracket):父母那一户下方画一个横跨它所有子女的
|
||||||
|
* 方括号,括号中心垂下一根线接到父母,每个子女各自的竖线接进同一根横杆。
|
||||||
|
* 谁是谁的父母/孩子因此一眼可辨,而不是"两代之间一条装饰竖线"。
|
||||||
|
* ③ 「妹夫、姐夫……谁是姐姐谁是妹妹是很明确的事情」等称呼
|
||||||
|
* → 全部在 Kinship::label() 里按长幼定死(本模板只渲染结果,不自己做判断)。
|
||||||
|
* ④ 「小姨子、小姑子、大姨子这种称呼,有错的,需要完全正确」
|
||||||
|
* → 同上;另对「妻子的父母」这类漂在图上的旁支标一句「姻亲」,免得看着像断线。
|
||||||
*/
|
*/
|
||||||
$gens = $tree['generations'];
|
$gens = $tree['generations'];
|
||||||
|
$units = $tree['units'] ?? [];
|
||||||
|
$unitName = $tree['unit_names'] ?? [];
|
||||||
$nodes = $tree['nodes'];
|
$nodes = $tree['nodes'];
|
||||||
$me = $nodes[$meId] ?? null;
|
$me = $nodes[$meId] ?? null;
|
||||||
|
|
||||||
@@ -22,10 +35,24 @@ $genWord = function (int $level): string {
|
|||||||
$down = [1 => '子女辈', 2 => '孙辈', 3 => '曾孙辈'];
|
$down = [1 => '子女辈', 2 => '孙辈', 3 => '曾孙辈'];
|
||||||
return $down[$level] ?? '下 ' . $level . ' 代';
|
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));';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>我的家谱</h1>
|
<h1>我的家谱</h1>
|
||||||
|
<span class="sub">同代按长幼从左到右 · 夫妻并排 · 括号表示谁是谁的父母</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (!$nodes || count($nodes) <= 1): ?>
|
<?php if (!$nodes || count($nodes) <= 1): ?>
|
||||||
@@ -37,14 +64,25 @@ $genWord = function (int $level): string {
|
|||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
|
||||||
<div class="tree">
|
<div class="tree">
|
||||||
<?php foreach ($gens as $level => $ids): ?>
|
<div class="tree-inner">
|
||||||
|
<?php foreach ($levels as $li => $level): ?>
|
||||||
|
<?php $levelUnits = $units[$level] ?? []; ?>
|
||||||
|
|
||||||
<div class="tree-gen">
|
<div class="tree-gen">
|
||||||
<div class="tree-gen-label">
|
<div class="tree-gen-label">
|
||||||
<span class="gname"><?= e($genWord((int)$level)) ?></span>
|
<span class="gname"><?= e($genWord((int)$level)) ?></span>
|
||||||
|
<span class="gtag"><?= count($levelUnits) ?> 户</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tree-row">
|
|
||||||
<?php foreach ($ids as $id): $n = $nodes[$id]; ?>
|
<div class="tree-row" style="<?= e($colStyle) ?>">
|
||||||
<a class="tnode <?= (int)$id === $meId ? 'is-me' : '' ?> <?= $n['ph'] ? 'is-placeholder' : '' ?>"
|
<?php foreach ($levelUnits as $u): ?>
|
||||||
|
<div class="tree-unit">
|
||||||
|
<?php foreach ($u['ids'] as $k => $id): ?>
|
||||||
|
<?php if ($k === 1): ?>
|
||||||
|
<span class="tree-bond" aria-label="夫妻"><i></i></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php $n = $nodes[$id]; ?>
|
||||||
|
<a class="tnode <?= (int)$id === $meId ? 'is-me' : '' ?> <?= $n['ph'] ? 'is-placeholder' : '' ?> <?= $u['inlaw'] && $k === 0 ? 'is-inlaw' : '' ?>"
|
||||||
href="<?= e(url('people/show', ['id' => $n['id']])) ?>"
|
href="<?= e(url('people/show', ['id' => $n['id']])) ?>"
|
||||||
title="<?= e($n['name'] . ' · ' . $n['label']) ?>">
|
title="<?= e($n['name'] . ' · ' . $n['label']) ?>">
|
||||||
<span class="tph"><img src="<?= e(avatar_url($n['avatar'])) ?>" alt=""></span>
|
<span class="tph"><img src="<?= e(avatar_url($n['avatar'])) ?>" alt=""></span>
|
||||||
@@ -53,12 +91,73 @@ $genWord = function (int $level): string {
|
|||||||
<?php if ($n['ph']): ?><span class="tph-mark">待确认</span><?php endif; ?>
|
<?php if ($n['ph']): ?><span class="tph-mark">待确认</span><?php endif; ?>
|
||||||
</a>
|
</a>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
<?php if ($u['inlaw']): ?>
|
||||||
</div>
|
<span class="tree-inlaw-tag" title="因婚姻进来的人,不是本家的血亲">姻亲</span>
|
||||||
<?php if ((int)$level !== (int)array_key_last($gens)): ?>
|
|
||||||
<div class="tree-link"></div>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
// ---- 父母括号:这一代(父母)↔ 下一代(子女)的连线 ----
|
||||||
|
//
|
||||||
|
// 画法(每个「父母户 + 它的子女们」一组):
|
||||||
|
// · 一根横杆盖住「父母那一列 → 它的子女各列」;
|
||||||
|
// · 横杆在**父母列的中心**向上伸一根竖线,接到父母那一户;
|
||||||
|
// · 横杆在**每个子女列的中心**向下伸一根短线,接到各自的子女户。
|
||||||
|
// 于是「谁是谁的父母与孩子」是几何上可读的,而不是两代之间一条装饰线。
|
||||||
|
$childLevel = $levels[$li + 1] ?? null;
|
||||||
|
if ($childLevel !== null && !empty($units[$childLevel])):
|
||||||
|
$kids = $units[$childLevel];
|
||||||
|
$ranges = []; // 父母单元key => [第一个子女列, 最后一个子女列]
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="tree-conn" style="<?= e($colStyle) ?>">
|
||||||
|
<?php foreach ($ranges as $pk => $rg): ?>
|
||||||
|
<?php
|
||||||
|
$pn = $unitName[$pk] ?? ['head' => '', '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;
|
||||||
|
?>
|
||||||
|
<div class="tc-bar" style="grid-column: <?= $lo + 1 ?> / <?= $hi + 2 ?>; grid-row: 1"
|
||||||
|
title="<?= e($who . ' 的子女') ?>">
|
||||||
|
<span class="tc-up" style="left: <?= round($pct, 3) ?>%"></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php foreach ($kids as $i => $ku): ?>
|
||||||
|
<div class="tc-down" style="grid-column: <?= $i + 1 ?>; grid-row: 1"></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="tree-legend">
|
||||||
|
「姻亲」= 因为婚姻进到这张图里的人(如我妻子的父母),不是本家的血亲。
|
||||||
|
括号横跨的那几户,就是括号上方那一户的子女。
|
||||||
|
</p>
|
||||||
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
// ⚠️ 「系统设置」**不在**顶栏里(用户明确要求):它已经收进右上角头像下拉菜单里了,
|
// ⚠️ 「系统设置」**不在**顶栏里(用户明确要求):它已经收进右上角头像下拉菜单里了,
|
||||||
// 顶栏留「首页 / 家谱 / 名册 / 观影」四个日常入口,别再把管理入口顶出来。
|
// 顶栏留「首页 / 家谱 / 名册 / 观影」四个日常入口,别再把管理入口顶出来。
|
||||||
// (v8 起加了「观影」—— MovieLog 整体迁入后的日常入口。)
|
// ⚠️ v11:「影院」也**不在**顶栏里了(用户明确要求)—— 影院是观影模块的一部分,
|
||||||
|
// 统一从观影列表页右侧的「设置」按钮进(那里还挂着高级搜索/统计/年度报告)。
|
||||||
|
// 别再往 $navItems 里加子功能入口,顶栏只放"模块"这一级。
|
||||||
$navItems = [
|
$navItems = [
|
||||||
'home' => ['首页', 'home'],
|
'home' => ['首页', 'home'],
|
||||||
'kin' => ['家谱', 'kin'],
|
'kin' => ['家谱', 'kin'],
|
||||||
'people' => ['名册', 'people'],
|
'people' => ['名册', 'people'],
|
||||||
'movies' => ['观影', 'movies'],
|
'movies' => ['观影', 'movies'],
|
||||||
'cinemas' => ['影院', 'movies/cinemas'],
|
|
||||||
];
|
];
|
||||||
// ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的,
|
// ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的,
|
||||||
// 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。
|
// 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。
|
||||||
|
|||||||
@@ -97,12 +97,13 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
|
|||||||
<label>同行的人</label>
|
<label>同行的人</label>
|
||||||
<?php /* v10:名册人多时 checkbox 网格会拉得很长 —— 改为「chips + 弹窗选择」。
|
<?php /* v10:名册人多时 checkbox 网格会拉得很长 —— 改为「chips + 弹窗选择」。
|
||||||
attendees[] 的 checkbox 仍保留(在弹窗列表里,选中即提交),无 JS 时
|
attendees[] 的 checkbox 仍保留(在弹窗列表里,选中即提交),无 JS 时
|
||||||
借 noscript 样式把弹窗平铺成普通勾选列表,提交语义完全不变。 */ ?>
|
借 noscript 样式把弹窗平铺成普通勾选列表,提交语义完全不变。
|
||||||
|
v11:chips 与弹窗列表都带上头像($p 来自 Person::roster,含 p.avatar)。 */ ?>
|
||||||
<?php if ($people): ?>
|
<?php if ($people): ?>
|
||||||
<div class="att-chips" id="attChips">
|
<div class="att-chips" id="attChips">
|
||||||
<?php foreach ($people as $p): ?>
|
<?php foreach ($people as $p): ?>
|
||||||
<?php if (in_array((int)$p['id'], $selAttendees, true)): ?>
|
<?php if (in_array((int)$p['id'], $selAttendees, true)): ?>
|
||||||
<span class="att-chip" data-id="<?= (int)$p['id'] ?>"><?= e($p['shown'] ?? $p['name']) ?><button type="button" class="att-x" data-id="<?= (int)$p['id'] ?>" title="移除">×</button></span>
|
<span class="att-chip" data-id="<?= (int)$p['id'] ?>"><img src="<?= e(avatar_url($p['avatar'] ?? null)) ?>" alt=""><span class="att-name"><?= e($p['shown'] ?? $p['name']) ?></span><button type="button" class="att-x" data-id="<?= (int)$p['id'] ?>" title="移除">×</button></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +123,8 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
|
|||||||
data-shown="<?= e((string)($p['shown'] ?? '')) ?>">
|
data-shown="<?= e((string)($p['shown'] ?? '')) ?>">
|
||||||
<input type="checkbox" name="attendees[]" value="<?= (int)$p['id'] ?>"
|
<input type="checkbox" name="attendees[]" value="<?= (int)$p['id'] ?>"
|
||||||
<?= in_array((int)$p['id'], $selAttendees, true) ? 'checked' : '' ?>>
|
<?= in_array((int)$p['id'], $selAttendees, true) ? 'checked' : '' ?>>
|
||||||
<span><?= e($p['shown'] ?? $p['name']) ?></span>
|
<img class="att-av" src="<?= e(avatar_url($p['avatar'] ?? null)) ?>" alt="" loading="lazy">
|
||||||
|
<span class="att-name"><?= e($p['shown'] ?? $p['name']) ?></span>
|
||||||
<span class="att-initials" hidden></span>
|
<span class="att-initials" hidden></span>
|
||||||
</label>
|
</label>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -233,12 +235,26 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
|
|||||||
chips.innerHTML = '';
|
chips.innerHTML = '';
|
||||||
Array.prototype.forEach.call(list.querySelectorAll('.att-item input:checked'), function (box) {
|
Array.prototype.forEach.call(list.querySelectorAll('.att-item input:checked'), function (box) {
|
||||||
var item = box.closest('.att-item');
|
var item = box.closest('.att-item');
|
||||||
var label = item.querySelector('span').textContent;
|
// ⚠️ 必须点名取 .att-name:v11 起 .att-item 里第一个元素是头像 <img>,
|
||||||
|
// 早先靠「取第一个 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 id = box.value;
|
||||||
var chip = document.createElement('span');
|
var chip = document.createElement('span');
|
||||||
chip.className = 'att-chip';
|
chip.className = 'att-chip';
|
||||||
chip.setAttribute('data-id', id);
|
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');
|
var x = document.createElement('button');
|
||||||
x.type = 'button'; x.className = 'att-x'; x.textContent = '×';
|
x.type = 'button'; x.className = 'att-x'; x.textContent = '×';
|
||||||
x.title = '移除'; x.setAttribute('data-id', id);
|
x.title = '移除'; x.setAttribute('data-id', id);
|
||||||
|
|||||||
@@ -23,10 +23,22 @@ $fmtNum = static function ($v): string {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</span>
|
</span>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies/cinemas')) ?>">影院管理</a>
|
|
||||||
<?php if ($canWrite): ?>
|
<?php if ($canWrite): ?>
|
||||||
<a class="btn" href="<?= e(url('movies/new')) ?>">记一场电影</a>
|
<a class="btn" href="<?= e(url('movies/new')) ?>">记一场电影</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php /* v11:右侧的「设置」按钮 —— 影院管理与从 MovieLog 迁来的
|
||||||
|
高级搜索 / 统计 / 年度报告都收在这里(顶栏不再单放「影院」)。 */ ?>
|
||||||
|
<details class="movie-set menu">
|
||||||
|
<summary class="btn btn-ghost btn-sm" title="观影设置">⚙ 设置</summary>
|
||||||
|
<div class="menu-list">
|
||||||
|
<?php if ($canWrite): ?>
|
||||||
|
<a href="<?= e(url('movies/cinemas')) ?>">影院管理</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="<?= e(url('movies/search')) ?>">高级搜索</a>
|
||||||
|
<a href="<?= e(url('movies/stats')) ?>">统计</a>
|
||||||
|
<a href="<?= e(url('movies/report')) ?>">年度报告</a>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 年度观影报告(v11,从 MovieLog annual_report.php 迁入)。
|
||||||
|
*
|
||||||
|
* ⚠️ 与 MovieLog 唯一的实质差异:那边月份/星期两张图靠外部图表库脚本渲染,
|
||||||
|
* 家书这边**不引任何外部脚本** —— 图表全部用 CSS 画(月份是竖柱、星期是横条)。
|
||||||
|
* 理由:线上不依赖外网 CDN 是既有纪律(家书连字体都是系统字体),
|
||||||
|
* 而且这两张图的信息量很小,CSS 完全够用。
|
||||||
|
*
|
||||||
|
* 「我」的口径与 MovieLog 一致:报告只统计**登录者参与过**的场次。
|
||||||
|
*/
|
||||||
|
$wd = ['日', '一', '二', '三', '四', '五', '六'];
|
||||||
|
$monthMax = max(1, max(array_values($stats['month_distribution'])));
|
||||||
|
$weekdayMax = max(1, max(array_values($stats['weekday_distribution'])));
|
||||||
|
$hasData = (int)$stats['total'] > 0;
|
||||||
|
$fmtDate = static function (?string $d): string {
|
||||||
|
return ($d !== null && $d !== '') ? date('Y年n月j日', strtotime($d)) : '—';
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<div class="page-head">
|
||||||
|
<h1><?= (int)$year ?> 年度观影报告</h1>
|
||||||
|
<span class="sub"><?= e($meName) ?> 这一年看了 <?= (int)$stats['total'] ?> 场</span>
|
||||||
|
<div class="actions">
|
||||||
|
<form method="get" action="index.php" class="year-pick">
|
||||||
|
<input type="hidden" name="r" value="movies/report">
|
||||||
|
<select class="select" name="year" onchange="this.form.submit()" aria-label="选择年份">
|
||||||
|
<?php foreach ($years as $y): ?>
|
||||||
|
<option value="<?= (int)$y ?>" <?= (int)$y === (int)$year ? 'selected' : '' ?>><?= (int)$y ?> 年</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies')) ?>">返回观影</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!$hasData): ?>
|
||||||
|
<div class="card card-pad empty">
|
||||||
|
<b><?= (int)$year ?> 年还没有你的观影记录</b>
|
||||||
|
换一个年份看看,或者先去记一场电影。
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
|
||||||
|
<div class="report-grid">
|
||||||
|
<div class="report-card"><div class="n"><?= (int)$stats['total'] ?></div><div class="l">观影场次</div></div>
|
||||||
|
<div class="report-card"><div class="n"><?= (int)$stats['days'] ?></div><div class="l">看电影的天数</div></div>
|
||||||
|
<div class="report-card"><div class="n"><?= e((string)$stats['avg_per_month']) ?></div><div class="l">月均场次</div></div>
|
||||||
|
<div class="report-card"><div class="n"><?= e((string)$stats['avg_days_between']) ?></div><div class="l">平均几天看一场</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="report-grid two">
|
||||||
|
<div class="report-card wide">
|
||||||
|
<div class="l">最常去的影院</div>
|
||||||
|
<div class="v"><?= e((string)$stats['top_cinema']) ?></div>
|
||||||
|
<?php if ((int)$stats['top_cinema_count'] > 0): ?><div class="s">去了 <?= (int)$stats['top_cinema_count'] ?> 次</div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="report-card wide">
|
||||||
|
<div class="l">一起看电影最多的人</div>
|
||||||
|
<div class="v"><?= e((string)$stats['top_person']) ?></div>
|
||||||
|
<?php if ((int)$stats['top_person_count'] > 0): ?><div class="s">同场 <?= (int)$stats['top_person_count'] ?> 次</div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="report-card wide">
|
||||||
|
<div class="l">同场人最多的一部</div>
|
||||||
|
<div class="v"><?= e((string)$stats['most_viewers_movie']) ?></div>
|
||||||
|
<?php if ((int)$stats['most_viewers_count'] > 0): ?><div class="s"><?= (int)$stats['most_viewers_count'] ?> 人一起看</div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="report-card wide">
|
||||||
|
<div class="l">这一年从 <?= e($fmtDate($stats['first_date'])) ?></div>
|
||||||
|
<div class="v">到 <?= e($fmtDate($stats['last_date'])) ?></div>
|
||||||
|
<div class="s"><?= (int)$stats['days'] ?> 个不同的日子走进电影院</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="section-title">月份分布 <span class="hint">每个月看几场</span></h3>
|
||||||
|
<div class="vgrid">
|
||||||
|
<?php foreach ($stats['month_distribution'] as $m => $cnt): ?>
|
||||||
|
<div class="vcol" title="<?= (int)$m ?> 月:<?= (int)$cnt ?> 场">
|
||||||
|
<span class="vbar" style="height: <?= e((string)round((int)$cnt / $monthMax * 100, 2)) ?>%"></span>
|
||||||
|
<span class="vlab"><?= (int)$m ?>月</span>
|
||||||
|
<span class="vnum"><?= (int)$cnt ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="section-title">星期分布 <span class="hint">哪天最常去</span></h3>
|
||||||
|
<div class="bar-chart">
|
||||||
|
<?php foreach ($wd as $w): $c = (int)$stats['weekday_distribution'][$w]; ?>
|
||||||
|
<div class="bar-item">
|
||||||
|
<span class="bar-label">周<?= e($w) ?></span>
|
||||||
|
<span class="bar-track"><span class="bar-fill bar-fill-alt" style="width: <?= e((string)round($c / $weekdayMax * 100, 2)) ?>%"></span></span>
|
||||||
|
<span class="bar-count"><?= $c ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($stats['keywords']): ?>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="section-title">这一年常出现的词 <span class="hint">从你的备注里数的</span></h3>
|
||||||
|
<div class="kw-cloud">
|
||||||
|
<?php foreach ($stats['keywords'] as $kw): ?><span class="kw"><?= e((string)$kw) ?></span><?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($stats['my_movies']): ?>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="section-title">这一年的片单 <span class="hint"><?= count($stats['my_movies']) ?> 部</span></h3>
|
||||||
|
<div class="waterfall">
|
||||||
|
<?php foreach ($stats['my_movies'] as $mv): ?>
|
||||||
|
<a class="movie-card" href="<?= e(url('movies/show', ['id' => (int)$mv['id']])) ?>">
|
||||||
|
<span class="card-image">
|
||||||
|
<?php if (!empty($mv['poster_key'])): ?>
|
||||||
|
<img src="<?= e(media_url($mv['poster_key'])) ?>" alt="<?= e($mv['title']) ?>" loading="lazy">
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="placeholder-poster">
|
||||||
|
<span class="ph-icon" aria-hidden="true">影</span>
|
||||||
|
<span class="placeholder-title"><?= e($mv['title']) ?></span>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
<span class="card-body">
|
||||||
|
<span class="card-title"><?= e($mv['title']) ?></span>
|
||||||
|
<span class="card-meta">
|
||||||
|
<span>📅 <?= e(\App\Movie::formatWatch($mv['starts_at'], null)) ?></span>
|
||||||
|
</span>
|
||||||
|
<?php if (!empty($mv['viewers'])): ?>
|
||||||
|
<span class="card-viewers">
|
||||||
|
<?php foreach ($mv['viewers'] as $vw): ?>
|
||||||
|
<span class="viewer-tag"><img src="<?= e(avatar_url($vw['avatar'] ?? null)) ?>" alt=""><?= e((string)$vw['name']) ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 高级搜索(v11,从 MovieLog search.php 迁入)。
|
||||||
|
*
|
||||||
|
* 与观影列表页顶部那个「一个关键词搜所有字段」的搜索栏不同:这里是**组合条件**——
|
||||||
|
* 片名 / 影院 / 观影人 / 日期范围 可以任意叠加,且观影人、影院是从下拉里点名选。
|
||||||
|
* 一个条件都不给时不查询,页面提示「先设置条件」(否则条件全空 = 全表,看着像卡了)。
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>高级搜索</h1>
|
||||||
|
<span class="sub">组合多个条件精确查找</span>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies')) ?>">返回观影</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" action="index.php" class="card card-pad adv-form">
|
||||||
|
<input type="hidden" name="r" value="movies/search">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="title">片名</label>
|
||||||
|
<input class="input" id="title" name="title" value="<?= e($p['title']) ?>" placeholder="输入片名的一部分即可">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="cinema_id">影院</label>
|
||||||
|
<select class="select" id="cinema_id" name="cinema_id">
|
||||||
|
<option value="0">全部影院</option>
|
||||||
|
<?php foreach ($cinemas as $c): ?>
|
||||||
|
<option value="<?= (int)$c['id'] ?>" <?= (int)$p['cinema_id'] === (int)$c['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= e(trim(((string)($c['place'] ?? '') !== '' ? $c['place'] . ' · ' : '') . $c['name'])) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="person_id">观影人</label>
|
||||||
|
<select class="select" id="person_id" name="person_id">
|
||||||
|
<option value="0">全部观影人</option>
|
||||||
|
<?php foreach ($persons as $pp): ?>
|
||||||
|
<option value="<?= (int)$pp['id'] ?>" <?= (int)$p['person_id'] === (int)$pp['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= e((string)$pp['name']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>日期范围</label>
|
||||||
|
<div class="date-range">
|
||||||
|
<input class="input" type="date" name="date_from" value="<?= e($p['date_from']) ?>">
|
||||||
|
<span class="to">至</span>
|
||||||
|
<input class="input" type="date" name="date_to" value="<?= e($p['date_to']) ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button type="submit" class="btn">搜索</button>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('movies/search')) ?>">清空条件</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if (!$hasCond): ?>
|
||||||
|
<div class="card card-pad empty">
|
||||||
|
<b>先设置一个条件</b>
|
||||||
|
填上片名、挑一家影院、指定某个人,或者框一段日期,再点「搜索」。
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="results-head">
|
||||||
|
<h3>搜索结果</h3>
|
||||||
|
<span class="sub">共 <?= count($rows) ?> 部</span>
|
||||||
|
</div>
|
||||||
|
<?php if (!$rows): ?>
|
||||||
|
<div class="card card-pad empty">
|
||||||
|
<b>没有找到符合条件的电影</b>
|
||||||
|
放宽一点条件再试。
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="waterfall">
|
||||||
|
<?php foreach ($rows as $mv): ?>
|
||||||
|
<a class="movie-card" href="<?= e(url('movies/show', ['id' => (int)$mv['id']])) ?>">
|
||||||
|
<span class="card-image">
|
||||||
|
<?php if (!empty($mv['poster_key'])): ?>
|
||||||
|
<img src="<?= e(media_url($mv['poster_key'])) ?>" alt="<?= e($mv['title']) ?>" loading="lazy">
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="placeholder-poster">
|
||||||
|
<span class="ph-icon" aria-hidden="true">影</span>
|
||||||
|
<span class="placeholder-title"><?= e($mv['title']) ?></span>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
<span class="card-body">
|
||||||
|
<span class="card-title"><?= e($mv['title']) ?></span>
|
||||||
|
<span class="card-meta">
|
||||||
|
<span>📅 <?= e(\App\Movie::formatWatch($mv['starts_at'], $mv['ends_at'] ?? null)) ?></span>
|
||||||
|
<?php if (!empty($mv['cinema_name'])): ?>
|
||||||
|
<span style="color: <?= e((string)($mv['cinema_color'] ?: '#888888')) ?>">🏛️ <?= e($mv['cinema_name']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
<?php if (!empty($mv['attendee_names'])): ?>
|
||||||
|
<span class="card-viewers"><span class="viewer-tag">👥 <?= e($mv['attendee_names']) ?></span></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
@@ -317,8 +317,11 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
|
|||||||
<div class="rv-head">
|
<div class="rv-head">
|
||||||
<?= $avatarHtml($rv['person_avatar'] ?? null, $rvName) ?>
|
<?= $avatarHtml($rv['person_avatar'] ?? null, $rvName) ?>
|
||||||
<span class="rv-who">
|
<span class="rv-who">
|
||||||
|
<?php /* v11:这里原本还有个写着「我」的徽标(类名与这个区块的首行同名)——
|
||||||
|
.rv-who 是 flex column,徽标被 align-items:stretch 拉成整行宽,
|
||||||
|
在星星旁边显出一条丑长的品牌色色带(用户反馈「特别长的条」),已删。
|
||||||
|
「是不是我」由删除按钮的存在表达,不需要徽标。 */ ?>
|
||||||
<span class="rv-name"><?= e($rvName) ?></span>
|
<span class="rv-name"><?= e($rvName) ?></span>
|
||||||
<?php if ($rvMine): ?><span class="rv-mine">我</span><?php endif; ?>
|
|
||||||
<span class="rv-time"><?= e(substr((string)$rv['created_at'], 0, 16)) ?></span>
|
<span class="rv-time"><?= e(substr((string)$rv['created_at'], 0, 16)) ?></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="rv-score" title="<?= e(\App\MovieReview::ratingWord($rvRate)) ?>"><?= $starsHtml($rvRate) ?><span class="rv-score-max"><?= $rvRate ?>/10</span></span>
|
<span class="rv-score" title="<?= e(\App\MovieReview::ratingWord($rvRate)) ?>"><?= $starsHtml($rvRate) ?><span class="rv-score-max"><?= $rvRate ?>/10</span></span>
|
||||||
@@ -352,7 +355,6 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
|
|||||||
<span class="rv-c-body">
|
<span class="rv-c-body">
|
||||||
<span class="rv-c-head">
|
<span class="rv-c-head">
|
||||||
<span class="rv-name"><?= e($cName) ?></span>
|
<span class="rv-name"><?= e($cName) ?></span>
|
||||||
<?php if ($cMine): ?><span class="rv-mine">我</span><?php endif; ?>
|
|
||||||
<span class="rv-time"><?= e(substr((string)$c['created_at'], 0, 16)) ?></span>
|
<span class="rv-time"><?= e(substr((string)$c['created_at'], 0, 16)) ?></span>
|
||||||
<?php if ($cMine && $canWrite): ?>
|
<?php if ($cMine && $canWrite): ?>
|
||||||
<form method="post" action="<?= e(url('movies/comment_del')) ?>" class="rv-del"
|
<form method="post" action="<?= e(url('movies/comment_del')) ?>" class="rv-del"
|
||||||
@@ -379,7 +381,6 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
|
|||||||
<span class="rv-c-body">
|
<span class="rv-c-body">
|
||||||
<span class="rv-c-head">
|
<span class="rv-c-head">
|
||||||
<span class="rv-name"><?= e($rName) ?></span>
|
<span class="rv-name"><?= e($rName) ?></span>
|
||||||
<?php if ($rMine): ?><span class="rv-mine">我</span><?php endif; ?>
|
|
||||||
<span class="rv-time"><?= e(substr((string)$rep['created_at'], 0, 16)) ?></span>
|
<span class="rv-time"><?= e(substr((string)$rep['created_at'], 0, 16)) ?></span>
|
||||||
<?php if ($rMine && $canWrite): ?>
|
<?php if ($rMine && $canWrite): ?>
|
||||||
<form method="post" action="<?= e(url('movies/comment_del')) ?>" class="rv-del"
|
<form method="post" action="<?= e(url('movies/comment_del')) ?>" class="rv-del"
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 观影统计仪表板(v11,从 MovieLog stats.php 迁入)。
|
||||||
|
*
|
||||||
|
* 版式对齐家书的暖纸配色(MovieLog 是深色卡片风 —— 只搬功能、不搬皮肤,
|
||||||
|
* 免得进到观影模块里突然变成另一个站点)。
|
||||||
|
* 条形图是纯 CSS 宽度百分比,不引任何图表库(线上不依赖 CDN)。
|
||||||
|
*/
|
||||||
|
$fmtPct = static function (int $v, int $max): float {
|
||||||
|
return $max > 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)) : '—';
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>观影统计</h1>
|
||||||
|
<span class="sub">一家人的观影概览</span>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies')) ?>">返回观影</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card"><div class="number"><?= (int)$ov['movies'] ?></div><div class="label">电影总数</div></div>
|
||||||
|
<div class="stat-card"><div class="number"><?= (int)$ov['cinemas'] ?></div><div class="label">影院数</div></div>
|
||||||
|
<div class="stat-card"><div class="number"><?= (int)$ov['persons'] ?></div><div class="label">观影人数</div></div>
|
||||||
|
<div class="stat-card"><div class="number"><?= (int)$ov['viewership'] ?></div><div class="label">总观影人次</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($ov['first_date'] !== null && $ov['last_date'] !== null): ?>
|
||||||
|
<div class="card card-pad stats-span">
|
||||||
|
<span class="label">观影跨度</span>
|
||||||
|
<span class="value"><?= e($wd($ov['first_date'])) ?> ~ <?= e($wd($ov['last_date'])) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3>按影院统计 <span class="hint">电影数量</span></h3>
|
||||||
|
<?php if ($byCinema): ?>
|
||||||
|
<div class="bar-chart">
|
||||||
|
<?php foreach ($byCinema as $r): $c = (string)($r['color'] ?: '#888888'); ?>
|
||||||
|
<div class="bar-item">
|
||||||
|
<span class="bar-label">
|
||||||
|
<span class="color-dot" style="background: <?= e($c) ?>"></span>
|
||||||
|
<?= e((string)$r['name']) ?><?php if (trim((string)$r['place']) !== ''): ?><span class="bar-sub"><?= e((string)$r['place']) ?></span><?php endif; ?>
|
||||||
|
</span>
|
||||||
|
<span class="bar-track"><span class="bar-fill" style="width: <?= e((string)$fmtPct((int)$r['cnt'], $maxCinema)) ?>%; background: <?= e($c) ?>"></span></span>
|
||||||
|
<span class="bar-count"><?= (int)$r['cnt'] ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="empty-hint">还没有影院数据。到「影院管理」加几家影院,记观影时就能选了。</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3>按观影人统计 <span class="hint">参与场次</span></h3>
|
||||||
|
<?php if ($byPerson): ?>
|
||||||
|
<div class="bar-chart">
|
||||||
|
<?php foreach ($byPerson as $r): ?>
|
||||||
|
<div class="bar-item">
|
||||||
|
<span class="bar-label">
|
||||||
|
<img class="bar-avatar" src="<?= e(avatar_url($r['avatar'] ?? null)) ?>" alt="">
|
||||||
|
<?= e((string)$r['name']) ?>
|
||||||
|
</span>
|
||||||
|
<span class="bar-track"><span class="bar-fill" style="width: <?= e((string)$fmtPct((int)$r['cnt'], $maxPerson)) ?>%"></span></span>
|
||||||
|
<span class="bar-count"><?= (int)$r['cnt'] ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="empty-hint">还没有观影人数据。</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3>按年统计 <span class="hint">观影数量</span></h3>
|
||||||
|
<?php if ($byYear): ?>
|
||||||
|
<div class="bar-chart">
|
||||||
|
<?php foreach ($byYear as $r): ?>
|
||||||
|
<div class="bar-item">
|
||||||
|
<span class="bar-label"><?= e((string)$r['y']) ?> 年</span>
|
||||||
|
<span class="bar-track"><span class="bar-fill bar-fill-alt" style="width: <?= e((string)$fmtPct((int)$r['cnt'], $maxYear)) ?>%"></span></span>
|
||||||
|
<span class="bar-count"><?= (int)$r['cnt'] ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="empty-hint">还没有年份数据。</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3>月度趋势 <span class="hint">最近 12 个月</span></h3>
|
||||||
|
<?php if ($byMonth): ?>
|
||||||
|
<div class="bar-chart">
|
||||||
|
<?php foreach ($byMonth as $r): ?>
|
||||||
|
<div class="bar-item">
|
||||||
|
<span class="bar-label"><?= e((string)$r['ym']) ?></span>
|
||||||
|
<span class="bar-track"><span class="bar-fill bar-fill-green" style="width: <?= e((string)$fmtPct((int)$r['cnt'], $maxMonth)) ?>%"></span></span>
|
||||||
|
<span class="bar-count"><?= (int)$r['cnt'] ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="empty-hint">还没有月度数据。</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('movies/report')) ?>">看年度报告</a>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('movies/search')) ?>">高级搜索</a>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user