Files
FamilyBook/templates/movies/stats.php
T
laoyang f9e30bd7d8 v40 第 40 批:观影九条 + 修掉一个「编辑一次就把观影时间弄成 NULL」的数据丢失 bug
用户原话(九条):
  ①「观影详情页面左上角的观影记录,应该和观影的列表页面里面保持一致」
  ②「观影详情页面的编辑,还有返回观影的按钮,应该和观影列表页面里面的记一场电影保持一致」
  ③「删除按钮就用红色背景颜色」
  ④「观影详情里面的编辑按钮应该也是弹出的,和新增是一样的,编辑完以后,页面动态更新」
  ⑤「影院管理部分,注意控制列的宽度…最右侧的操作、编辑和删除两个按钮,已经被挤成两行了,
     尝试让编辑和删除排在一行,宽度给它留够」
  ⑥「编辑使用蓝色的背景,删除使用红色的背景」
  ⑦「高级搜索里面的日期范围,也使用我们最新的控件」
  ⑧「观影统计里面,如果这个人在名册里面,但是他一部电影的记录也没有,就不要在下面的这个
     按观影人统计里面出现了,太长了」
  ⑨「观影统计里面,月份分布和星期分布,12 个月用 12 种不同的颜色,星期 7 天用 7 种不同的颜色」

🔴🔴 **本轮最重:修掉一个数据丢失级的真 bug**(v37 引入、**影响所有 `.dt-range` 页面**)
  · 现象:任何带已存时间的编辑表单,只要 JS 跑起来,日期在用户**什么都没动**时
    就被抹掉 ⇒ 提交后库里 `starts_at` 变 **NULL**。
  · 根因:`assets/js/app.js` 的 `bindParts()` 首屏写成 `rebuildDays(false)` ——
    那个参数是"保留已选日吗",传 false 会**清空"日"下拉**;紧接着的 `sync()`
    看到 y/mo 有值而 d 空 ⇒ 算出 '' ⇒ **把隐藏的 `*_date` 写成空串**。
  · 修:改成 `rebuildDays(true)`。
  · ⚠️ 为什么一直没被发现:e2e 是**服务端**驱动的(直接 POST 字段、不经过 JS);
    真机探针只验"控件可见可点"与"改值能写回",**没验"什么都不动时值还在不在"**。
    ⇒ 补了回归断言 + 反向验证(把 true 改回 false 必须红)。
  · ⚠️ 影响面大于观影:**日程 / 任务 / 旅程**的起止同样走 `.dt-range`,
    修在共用件等于顺手修了四个模块。

① 详情页头部与列表页统一:`<span class="sub">观影记录</span>` → **`<h1>观影记录</h1>` + sub=片名**。
②③ 详情页「编辑」「返回观影」用**裸 `.btn`**(= 列表页「记一场电影」的模块品牌实心);
   「删除」用 `.btn-del`(**红色实心**,原来是淡红描边的 `.btn-danger`)。
   ⚠️ 用户那句"编辑用蓝色"是**影院管理**的(见 ⑥),详情页要的是"与列表页一致" ——
      第一版理解错、把详情页编辑也刷成蓝色,**反向验证当场抓住**。
④ 详情页「编辑」改**弹框** + AJAX + 保存后**动态更新**:
   · 内嵌**同一份** `form.php`;新增 `$mvModalForce`(`$isEdit` 也强制弹框壳)
     与可显式传的 `$mvOpen`;新增 `View::renderPartial()` 供校验失败时把弹框整块重画回 JSON。
   · 保存成功:`fetch` 跟 302 → **只换正文容器**(保留滚动位置),并重跑页内内联 JS。
   · 🔴 实测踩到并解决的四件事:
     1. 模板里裸类名 `Person::roster()` 会**致命**(全局命名空间)⇒ 页面从此中断输出、
        且不报给用户 ⇒ 弹框整块消失。必须 `\App\Person::`。
     2. `<button form="mvForm">`(表单外的提交按钮):点它真的提交,但挂在 `document` 上的
        `submit` 监听**收不到事件**(实测 `XRW=(无)`、`resource_type=document`)⇒ 整页跳走、
        红字看不到 ⇒ 主路改成 **click 委托**(preventDefault),`submit` 只兜回车。
     3. `innerHTML` 换正文容器后要重跑内联 JS,但**绝不能重跑带 `src` 的外部脚本**
        (app.js 等,会二次绑定);本段脚本自己要有**幂等闸门**。
     4. flash 是**一次性**的(`take_flash()` 读完即清)⇒ 成功后**不要**再取一次页面,
        直接用第一次的 `r.text()`(否则"保存成功却没有任何提示")。
⑤⑥ 影院管理:`.cn-table { table-layout: fixed }` + 操作列 **132px** +
   `.cn-ops { flex-wrap: nowrap }` ⇒ 编辑/删除**并排一行**;
   新增语义色 `--blue: #2F6FB0`,编辑=蓝实心、删除=红实心
   (⚠️ 必须在**页头动作区那一层**也写一条:`.btn` 的特异性会盖过基类)。
⑦ 高级搜索的日期范围换 AntD:新增 `[data-antd-daterange]`(**纯日期**版,
   不传 showTime),与观影"时刻"那对共用同一套按需加载 + 中文 locale 逻辑。
⑧ `MovieStats::byPerson` 加 `HAVING cnt > 0`(⚠️ 不能写 `WHERE cnt > 0`:
   聚合结果在 WHERE 阶段还不存在)。
⑨ 新增 `MovieStats::MONTH_COLORS`(12 色)/ `WEEKDAY_COLORS`(7 色)+
   `monthColor()` / `weekdayColor()`;**统计页"月度趋势"与年度报告"月份分布"同月同色**
   (用户没要求,但不一致一眼就看得出)。

验证(全部真机 + 反向验证):
  · _fb_b40_movie.py **41/0**(九条逐条 + 两条 🔴🔴 日期不丢);
  · _fb_b40_falsify.py **10/0**(日期抹掉 / 编辑按钮配色 / AJAX 主路 三处各自证伪 + 复原回全绿);
  · 全路由 e2e **1502/0**(顺带修掉一条**依赖夹具数据**的脆弱断言:
    "统计页必须有 bar-chart" → 无数据时改判"要么有图表、要么有空态");
  · 页头按钮探针 **60/0** + 反向 **3/3 全部抓到**(删除按钮换成 `.btn-del` 后,
    判据与注入目标都已同步更新);v37/v38/v39 各套全绿;
  日期控件真机 53/0 + 反向 7/0;观影照片 16/0 + 8/0;DAV 三套;部署静态断言空跑 —— 全绿。
  · 线上 11 个关键文件 SHA256 与本地**逐字节一致**。

⚠️ 顺带:`show.php` 的 JS 注释里写了字面 `<script>` / `<main>`,绊倒了我自己"按标签
  提取内联 JS 做语法检查"的脚本(**与 v37 那次 `<form>` 同类**)—— 已把注释里的
  标签字面量全部改写。⇒ 通识:模板注释里不要出现字面标签名。
2026-10-10 22:13:20 +08:00

131 lines
5.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 观影统计仪表板(v11,从 MovieLog 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" href="<?= e(url('movies')) ?>">返回观影</a>
<?php $setCurrent = 'stats'; $setCanWrite = \App\Auth::canWrite(); require __DIR__ . '/_settings_menu.php'; ?>
</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(person_name($r)) ?>
</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 /* 🔴 v40:与年度报告的「月份分布」**用同一套月配**(`MovieStats::MONTH_COLORS`)——
同一个月在两个页面必须是同一种颜色,否则用户会以为那是两件不同的事。
`$r['ym']` 形如 '2026-10',取月份部分查色。 */ ?>
<?php if ($byMonth): ?>
<div class="bar-chart">
<?php foreach ($byMonth as $r): $ymM = (int)substr((string)$r['ym'], 5, 2); ?>
<div class="bar-item">
<span class="bar-label"><?= e((string)$r['ym']) ?></span>
<span class="bar-track"><span class="bar-fill"
style="width: <?= e((string)$fmtPct((int)$r['cnt'], $maxMonth)) ?>%;
background: <?= e(\App\MovieStats::monthColor($ymM)) ?>"></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>