Files
FamilyBook/templates/movies/cinemas.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

158 lines
7.2 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
/**
* 影院管理(v9,MovieLog cinemas.php 同款):上面加/编表单,下面列表。
*
* 颜色用「取色器 + 50 色预设板」两路输入(内联小段 JS 只是取色联动,
* 不依赖 JS 也能手填 hex 或直接用取色器)。删除是 POST 小表单 + confirm;
* 删掉的影院只把关联观影记录的 cinema_id 置空,记录本身不动。
*
* ⚠️ $editing 是路由传进来的「被编辑影院的记录数组」(或 null)。
* 曾经这里写成 `$editing = !empty($editing);` —— 数组被原地变成布尔值,
* 表面看标题还会显示「编辑影院」,但 hidden id 变 0、四个字段全部回填为空,
* 点「编辑」就像没带入一样(v9 线上实测)。布尔化结果必须存进**另一个**变量。
*/
$isEditing = !empty($editing);
$canWrite = \App\Auth::canWrite();
?>
<div class="page-head">
<h1>影院管理</h1>
<span class="sub">共 <?= count($cinemas) ?> 家影院</span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('movies')) ?>">返回观影</a>
<?php $setCurrent = 'cinemas'; $setCanWrite = $canWrite; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<?php if (!$canWrite): ?>
<div class="card card-pad empty"><b>只读账号</b>访客只能查看影院列表。</div>
<?php else: ?>
<div class="card card-pad cinema-form-card">
<h2 class="cinema-h2"><?= $isEditing ? '编辑影院' : '添加影院' ?></h2>
<form method="post" action="<?= e(url('movies/cinemas_save')) ?>">
<?= csrf_field() ?>
<?php if ($isEditing): ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<div class="field">
<label for="cname">影院名称 *</label>
<input class="input" id="cname" name="name" required
value="<?= e(old('name', $editing['name'] ?? '')) ?>" placeholder="如:万达影城(IMAX 店)">
</div>
<div class="field">
<label for="cplace">所在地方</label>
<input class="input" id="cplace" name="place"
value="<?= e(old('place', $editing['place'] ?? '')) ?>" placeholder="如:青岛 · 崂山区">
</div>
<div class="field full">
<label for="caddress">详细地址</label>
<input class="input" id="caddress" name="address"
value="<?= e(old('address', $editing['address'] ?? '')) ?>">
</div>
<div class="field full">
<label for="ccolor">主题颜色</label>
<div class="color-row">
<input type="color" id="ccolor" name="color" class="color-picker"
value="<?= e(old('color', $editing['color'] ?? '#888888')) ?>">
<span class="color-hex" id="colorHex"><?= e(old('color', $editing['color'] ?? '#888888')) ?></span>
<span class="preset-palette">
<?php foreach (\App\Cinema::PRESET_COLORS as $pc): ?>
<span class="preset-color" data-color="<?= $pc ?>" style="background: <?= $pc ?>;" title="<?= $pc ?>"></span>
<?php endforeach; ?>
</span>
</div>
</div>
</div>
<div class="btn-row">
<button type="submit" class="btn"><?= $isEditing ? '保存' : '添加影院' ?></button>
<?php if ($isEditing): ?>
<a class="btn btn-ghost" href="<?= e(url('movies/cinemas')) ?>">取消编辑</a>
<?php endif; ?>
</div>
</form>
</div>
<?php endif; ?>
<div class="card card-pad">
<h2 class="cinema-h2">影院列表</h2>
<?php if (!$cinemas): ?>
<p class="empty-hint">还没有影院,先在上面添加一家。</p>
<?php else: ?>
<div class="table-wrap">
<?php /* 🔴 v40(用户 2026-10-10):「影院管理部分,注意控制列的宽度,因为最右侧的
操作、编辑和删除两个按钮,已经被挤成两行了,尝试让编辑和删除排在一行,
宽度给它留够。编辑使用蓝色的背景,删除使用红色的背景。」
⇒ ① 表格加 `.cn-table`:给「操作」列一个**最小宽度 + 不换行**
(`.cn-ops` 上 `flex-wrap: nowrap` + `white-space: nowrap`)——
两个按钮被挤成两行正是因为该列没有下限、被长地址挤压;
② 编辑 → `.btn-edit`(实心蓝)、删除 → `.btn-del`(实心红)。
⚠️ 用 `.btn-edit` / `.btn-del` 而不是复用 `.btn-danger`:后者是**淡红描边**的
"危险动作"语义,全站十来处删除都在用;用户这次明确要的是**实心色块**。 */ ?>
<table class="table table-fixed cn-table">
<thead>
<tr>
<th class="cn-col-name">名称</th>
<th class="cn-col-place">所在地方</th>
<th class="cn-col-addr">详细地址</th>
<th class="cn-col-count">观影</th>
<?php if ($canWrite): ?><th class="cn-col-ops">操作</th><?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($cinemas as $c): ?>
<tr>
<td>
<span class="color-preview" style="background: <?= e((string)($c['color'] ?: '#888888')) ?>;"></span>
<b><?= e($c['name']) ?></b>
</td>
<td><?= e((string)($c['place'] ?? '')) !== '' ? e($c['place']) : '—' ?></td>
<td class="cn-addr"><?= !empty($c['address']) ? e($c['address']) : '—' ?></td>
<td><?= (int)$c['movie_count'] ?> 场</td>
<?php if ($canWrite): ?>
<td class="cn-col-ops">
<div class="actions cn-ops">
<a class="btn btn-edit btn-sm" href="<?= e(url('movies/cinemas', ['edit' => (int)$c['id']])) ?>">编辑</a>
<form method="post" action="<?= e(url('movies/cinemas_del')) ?>"
onsubmit="return confirm('确定删除「<?= e($c['name']) ?>」吗?关联观影记录的影院信息将置空。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<button type="submit" class="btn btn-del btn-sm">删除</button>
</form>
</div>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php if ($canWrite): ?>
<script>
(function () {
var picker = document.getElementById('ccolor');
var hex = document.getElementById('colorHex');
if (!picker || !hex) return;
function setActive(color) {
Array.prototype.forEach.call(document.querySelectorAll('.preset-color'), function (el) {
el.classList.toggle('active', el.getAttribute('data-color') === color);
});
}
picker.addEventListener('input', function () {
hex.textContent = picker.value;
setActive(picker.value);
});
Array.prototype.forEach.call(document.querySelectorAll('.preset-color'), function (el) {
el.addEventListener('click', function () {
picker.value = el.getAttribute('data-color');
hex.textContent = picker.value;
setActive(picker.value);
});
});
setActive(picker.value);
})();
</script>
<?php endif; ?>