Files
FamilyBook/templates/layout.php
T
laoyang 8661ab6475 v38 第 38 批:观影试点 Ant Design 日期控件 + 弹框内联排版(对齐)+ 去提示 + 默认带自己
用户原话(四条):
  ①「我终于找到了一个我比较认可的日期选择插件
     https://ant-design.antgroup.com/components/date-picker-cn 现在观影这个模块进行
     测试吧,测试那个有开始、结束时间的。等这个模块测试完没问题了,再更新到其他的模块。」
  ②「记录一场电影,这个弹框因为标题和下面的输入框都是两行的,所以导致弹框非常长。
     能不能排排版,让前面的标题后面加个冒号,后面的输入框直接跟在它后面,这样就变成一行。
     包括开始、结束时间、影院、同行的人、简介等等,能把它排成一行的都排成一行,
     但是一定要注意对齐的问题,如果不对齐的话可能不好看。」
  ③「里面的一些提示,比如片名可以从豆瓣自动补充,还有刚新建的影院会自动选中,
     观影照片可以放这里且可多选,这些都把它去掉吧,节省一下上下的空间。」
  ④「另外一个同行的人,这里每个人添加的时候,应该默认带上自己,也就是自己不用选择
     就应该已经选上了。」

① Ant Design RangePicker —— **只在观影试点**(用户明确"试完再推广")
  · 资源 `assets/vendor/antd/{antd.min.js, dayjs.min.js, reset.css}`(unpkg 下载、随包上线);
    layout.php 加 `fb-antd` 清单;`_datetime.php` 加 `$dtPlugin = 'antd'` 开关 +
    `data-dt-antd="1"` 标记;app.js 加 `bootAntd()` / `mountAntd()`(按需加载)。
  · 🔴 UMD 的硬依赖:antd 从**全局**取 React / ReactDOM / dayjs。缺 React 的报错是
    `Cannot read properties of undefined (reading 'createContext')` —— 表现是"控件完全
    不出现",不是样式问题。项目本来就有一套 React 18.3.1(Semi 用的),直接复用同一份。
  · 🔴 实测出的关键行为(独立探针 `_antd_probe`,15/0):面板里有 **42 个日期格 +
    2 个内联时间列(84 项)+ 「确定」按钮** —— 正是之前 Semi 缺的那两样;
    且 **`onChange` 只在两端都齐了才触发**,单端改动走 `onCalendarChange` ⇒
    回写必须两个都挂,否则"只填了开始时间就提交"会丢值。
  · ⚠️ 原生那对下拉**全部保留**(值载体 + 拉不动时的兜底),挂上 antd 后用 clip 视觉收起
    (不用 display:none —— 保持可聚焦)。
  · 部署断言钉住试点边界:日程/任务**不该**出现 `$dtPlugin = 'antd'`。

② 弹框排版:标签内联 + 控件左缘对齐
  · `.mv-row` = 标签(固定 62px、右对齐、**冒号由 CSS ::after 给**):控件(占满剩余)。
  · 并排对:简介+备注、海报+观影照片 → `.mv-pair`(grid 1fr 1fr 保证两列等宽);窄屏回单列。
  · 🔴 真机量出**两个真对齐缺陷**(不是猜的):
    1) 时间行的控件左缘比别的行**偏 8px** —— `.dt-cols` 是 `gap: 10px 18px`(列距 18),
       而普通 `.mv-row` 只有 `gap:10px` ⇒ 改 `.mv-row.dt-range.dt-cols { column-gap: 10px }`;
    2) 海报/观影照片两行歪了 **130px** —— `_stage_drop.php` 在最前面插的 `.sr-only`
       `<input>`(绝对定位 1px)**占掉了一整份 flex:1 1 auto**,把上传框推出去
       ⇒ 这两块改纵向 `.mv-media`(标签一行、内容从 72px 起,与主列对齐)。
    ⇒ 现在**所有字段控件左缘都是 444**;A/B 实测**弹框 1036px → 817px(矮 219px)**。

③ 删掉三句冗余提示(豆瓣补充 / 新建影院自动选中 / 观影照片),简介 rows 6→3。

④ 同行的人默认带上自己:新增时预置 `[我]`;编辑以库值为准;`old()` 存在时尊重用户上次勾选。

🔴 顺带修的真 bug:**antd 面板与弹框抢 Esc** —— 两个处理器都挂 document 上,
  按一次 Esc 把日期面板和整个弹框一起关掉。已改成"antd 面板开着时弹框不吃 Esc"。

验证(全部真机 + 反向验证):
  · _fb_b38_movie_form.py **33/0**(antd 挂上/面板有时间列与确定/值写回隐藏 *_date/
    标签同行与左缘对齐/三句提示消失/默认带自己/试点边界);
  · _fb_b38_narrow.py **6/0**(宽屏并排、窄屏回单列、无横向溢出);
  · _fb_b38_falsify.py **8/0**(排版退回上下两行、antd 只挂 onChange、不预置自己 → 各自必须红);
  · e2e **1503/0**、日期控件真机 **53/0** + 反向 **7/0**、v37 两套 **27/0 + 5/0**、
    观影照片 **16/0** + **8/0**、b30 三件套、b18 选人 **10/0**、几何交互 **109/0**、
    DAV 三套、部署静态断言空跑 —— 全绿;
  · 线上 app.js / app.css / antd.min.js 的 SHA256 与本地一致。

⚠️ 顺带修了探针/夹具的三处**测试侧**问题(都不是页面 bug):
  · e2e 里 6 条 jsdom 用例(测选人弹框"暂存区机制")前提是"一个都没选",
    默认带自己后全假红 ⇒ 在**测试自己的 HTML 副本**上清掉 `data-pp-selected`;
  · `_fb_b18_picker` 真机探针同理,改成**点 chip 上的 ×**(组件把已选存在闭包里,
    改属性没用 —— 试过两种写法都是白试);
  · `_fb_b34_dtrange` 的"标签文案"断言写死了「开始 / 结束」,
    而 v38 把观影的标签简化成了「时间」⇒ 改成"有非空标签即可";
    另补一条 antd 分支(挂 antd 后原生被裁成 1px,`1px > 0` 会让旧判据**假通过**)。
  · 顺带:夹具名册只有 1 人时会误报"无法验证多选",已把实际候选数打出来。
2026-10-10 11:01:22 +08:00

248 lines
15 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:「影院」也**不在**顶栏里了(用户明确要求)—— 影院是观影模块的一部分,
// 统一从观影列表页右侧的「设置」按钮进(那里还挂着高级搜索/统计/年度报告)。
// 别再往 $navItems 里加子功能入口,顶栏只放"模块"这一级。
// ⚠️ v14 起导航条**不再写死**:模块清单在 App\Module::LIST 里(加一个模块只改那一处),
// 显示哪些、什么顺序由**每个人自己**的 user_settings('nav_modules') 决定
// (用户第 10 条:允许设置哪些模块出现在导航条上,拖动排序)。
// App\Module::navFor() 内部做了两道兜底(剔除已不存在的模块、补上新装的模块),
// 这里直接拿结果用,不要再自己过滤一遍。
$navKeys = \App\Module::navFor((int)\App\Auth::personId());
$navItems = [];
foreach ($navKeys as $k) {
$navItems[$k] = [\App\Module::name($k), \App\Module::route($k)];
}
// 本页属于哪个模块 → 决定主题色(用户第 11 条:每个模块一个主题色)
$moduleKey = \App\Module::ofPage((string)$pageKey);
if ($moduleKey === '') {
$moduleKey = 'home';
}
$themeVars = \App\Module::cssVars($moduleKey, (int)\App\Auth::familyId());
// ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的,
// 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。
// home.php 用 $me 画「补充亲属关系」链接,被覆盖成 true 之后
// 链接就变成 people/show&id=0,还顺带在页首打出一条 Warning。
// 改名前请先确认所有模板都没有依赖这个布尔值。
$isLoggedIn = \App\Auth::check();
?>
<!DOCTYPE html>
<html lang="zh-CN" class="no-js">
<head>
<?php /* v24 第 12 批 #2:解决「打开页面先闪一下原生输入框,然后才变成 Semi 控件」。
做法是经典的 **no-js / js 二态**:
· 服务端先渲染 class="no-js"(此时原生控件可见 = 无 JS 也能用);
· 下面这段内联脚本**在 <head> 里立即把 class 换成 js** ——
它在 CSS 生效前、任何渲染之前就跑了,所以浏览器**根本没机会**画出原生框;
· CSS 里 `.js .dt-input .dt-native { visibility:hidden }` 先把原生藏住
(用 visibility 而不是 display:none:保留占位高度,避免升级瞬间布局跳动);
· app.js 的 Semi 就绪后加 `.is-semi` 把 Semi 显示出来(原生继续藏着)。
这样"闪烁"从**结构上**消失,而不是靠调加载顺序去掩盖。 */ ?>
<script>document.documentElement.className =
document.documentElement.className.replace('no-js', 'js');</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($pageTitle) ?> · 家书</title>
<link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="theme-color" content="#A84A28">
<link rel="stylesheet" href="<?= e(asset('/assets/css/app.css')) ?>">
<?php /* v24 第 9 批 #9:Semi Design DatePicker 懒加载清单(用户点名 semi.design)。
这里只登记带版本号的资源地址,**不加载** —— app.js 只有在页面上真出现
.dt-input(templates/_datetime.php)时才去下这 4 个文件(合计约 4 MB,
绝不能让没有日期控件的页面白拉)。 */ ?>
<meta name="fb-semi" content="<?= e(json_encode([
'css' => asset('/assets/vendor/semi/semi.min.css'),
'react' => asset('/assets/vendor/semi/react.min.js'),
'dom' => asset('/assets/vendor/semi/react-dom.min.js'),
'ui' => asset('/assets/vendor/semi/semi-ui.min.js'),
], JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
<?php /* v24 第 11 批 #5:Cropper.js(裁剪)也是**按需加载** ——
只有页面上真有 input[type=file][data-crop-ratio] 时才去拉(见 app.js)。
同样在这里登记带版本号的地址,绝不在没有裁剪的页面白拉。 */ ?>
<meta name="fb-cropper" content="<?= e(json_encode([
'css' => asset('/assets/vendor/cropper/cropper.min.css'),
'js' => asset('/assets/vendor/cropper/cropper.min.js'),
], JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
<?php /* 🔴🔴 v38(用户 2026-10-10):「我终于找到了一个我比较认可的日期选择插件
https://ant-design.antgroup.com/components/date-picker-cn 现在观影这个模块
进行测试吧…等这个模块测试完没问题了,再更新到其他的模块。」
⇒ Ant Design 的 RangePicker **只在观影模块试点**(其余页面继续用自建下拉)。
同样按需加载:只有页面真出现 `[data-dt-antd]` 才去拉这三个文件
(antd.min.js 单文件约 1.5 MB,绝不能让用不到的页面白拉)。
⚠️ 必须同时引 `reset.css` —— antd 的组件依赖它做 box-sizing/边距归一,
不引会让面板里的日期格子错位(它是 antd 官方文档里指定的用法)。
⚠️ `dayjs` 也要引:antd 5 的值类型是 dayjs 对象,UMD 版从全局 `dayjs` 取。 */ ?>
<meta name="fb-antd" content="<?= e(json_encode([
'css' => asset('/assets/vendor/antd/reset.css'),
'antd' => asset('/assets/vendor/antd/antd.min.js'),
'dayjs' => asset('/assets/vendor/antd/dayjs.min.js'),
/* ⚠️ antd 的 UMD **要求全局有 React / ReactDOM**(缺一个就直接挂不出来 ——
实测报错是 `Cannot read properties of undefined (reading 'createContext')`)。
项目里已经有一套 React 18.3.1(原本给 Semi 用的),**直接复用同一份**:
同一个 UMD 文件、同版本,先后加载只会重复赋值、不会冲突。
⇒ 主版本要动的话,这两处(fb-semi / fb-antd)必须一起动。 */
'react' => asset('/assets/vendor/semi/react.min.js'),
'dom' => asset('/assets/vendor/semi/react-dom.min.js'),
], JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
</head>
<?php /* 主题色:把 --brand / --brand-2 / --brand-ink 三个变量在 <body> 上换成本模块的色,
下面所有用到品牌色的地方(按钮、链接、页头高亮…)自动跟着变。
⚠️ 只覆盖这三个变量,**不碰** --paper / --ink 这些纸墨底色 ——
模块色是用来"分区"的,把整页背景也换了就不是家书了。 */ ?>
<body class="mod-<?= e($moduleKey) ?>"
data-media-base="media.php?k="
style="--brand: <?= e($themeVars['brand']) ?>; --brand-2: <?= e($themeVars['brand2']) ?>; --brand-ink: <?= e($themeVars['ink']) ?>;">
<?php if ($isLoggedIn): ?>
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="<?= e(url('home')) ?>">
<img src="<?= e(asset('/assets/icons/icon-96.png')) ?>" alt="">
<span>家书</span>
</a>
<nav class="nav">
<?php foreach ($navItems as $key => list($label, $route)): ?>
<?php /* 高亮按「本页属于哪个模块」判,而不是 pageKey 全等 ——
子页(观影里的影院管理/统计/年度报告)也要让「观影」亮着。 */ ?>
<a class="<?= $moduleKey === $key ? 'on' : '' ?>" href="<?= e(url($route)) ?>"><?= e($label) ?></a>
<?php endforeach; ?>
</nav>
<div class="topbar-right">
<?php /* 🔴 v32 第 3 条:浏览器提醒的**开关**(小铃铛)。
用户原话:「…则需要在 9:50 时浏览器中发提醒通知。」
⚠️ 浏览器**不会**允许"页面加载时自动申请通知权限"(Chrome 会直接静默拒绝,
并把域名记进"滥用"名单),所以权限必须由用户**点一下**才要。
⇒ 这里放一个按钮,由 assets/js/notify.js 绑定。
⚠️ 已经授权时(或浏览器不支持 Notification 时)按钮自动隐藏 ——
见 notify.js 的 paint()。所以未授权的人才会看到它。 */ ?>
<button type="button" class="btn btn-ghost btn-icon fb-notify-enable" hidden
title="开启浏览器提醒" aria-label="开启浏览器提醒">
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 7a4 4 0 0 1 8 0v3l1 2H3l1-2z"/>
<path d="M6.5 12.5a1.6 1.6 0 0 0 3 0"/>
</svg>
</button>
<?php /* 姓名 + 头像 = 一个下拉菜单,而不是「点名字改资料」+旁边两个裸链接。
裸链接在窄屏只能靠 display:none 藏起来(一藏入口就没了),
收进菜单之后同一个入口在手机上也点得到。
用 <details> 实现:不需要 JS 就能展开/收起,app.js 只额外补
"点外面"和"Esc"两件事。 */ ?>
<details class="menu who-menu">
<summary class="who" title="我的">
<img src="<?= e(avatar_url($_SESSION['avatar'] ?? null)) ?>" alt="">
<span class="who-name"><?= e(\App\Auth::name()) ?></span>
<?php if (\App\Auth::isGuest()): ?><span class="badge">访客</span><?php endif; ?>
<i class="caret" aria-hidden="true"></i>
</summary>
<div class="menu-list">
<?php
/* 🔴 v30 第 5 条:「引入通知模块,这个模块,不在导航显示,在个人头像点击那里显示,
放在个人设置前面,名字:通知消息。」
⇒ 它排在**个人设置之前**,且带一个未读角标(有几条没看)。
⚠️ 这是通知唯一的入口 —— 顶栏不渲染它(它不在 Module::LIST,
见 app/Notification.php 头部的第 ①条)。 */
$fbUnread = \App\Notification::unreadCount((int)\App\Auth::personId());
?>
<a href="<?= e(url('notifications')) ?>">
通知消息
<?php if ($fbUnread > 0): ?>
<span class="menu-badge"><?= (int)$fbUnread ?></span>
<?php endif; ?>
</a>
<a href="<?= e(url('settings/me')) ?>">个人设置</a>
<?php if (\App\Auth::isAdmin()): ?>
<a href="<?= e(url('settings')) ?>">系统设置</a>
<?php endif; ?>
<a href="<?= e(url('auth/password')) ?>">修改密码</a>
<a class="menu-exit" href="<?= e(url('auth/logout', ['_csrf' => csrf_token()])) ?>">退出登录</a>
</div>
</details>
</div>
</div>
</header>
<?php endif; ?>
<main class="<?= in_array($pageKey, ['install', 'login'], true) ? 'container-narrow' : 'container' ?>">
<?php foreach (take_flash() as $f): ?>
<div class="flash">
<div class="flash-item <?= $f['type'] === 'err' ? 'err' : '' ?>"><?= e($f['message']) ?></div>
</div>
<?php endforeach; ?>
<?php require $tplFile; ?>
</main>
<?php /* 全局附件预览层(v24 第 3 条):图片 / 视频 / PDF 都在本页这一层里看,
右上角恒有下载按钮。内容由 assets/js/preview.js 按 data-prev 现填。 */ ?>
<div class="pv" id="pv" role="dialog" aria-modal="true" aria-hidden="true" aria-label="附件预览" hidden>
<div class="pv-head">
<span class="pv-name"></span>
<span class="pv-meta"></span>
<a class="pv-dl" href="#">下载</a>
<button type="button" class="pv-close" aria-label="关闭">✕</button>
</div>
<div class="pv-stage"></div>
<?php /* v24 第 19 批 用户第 6 条:左右箭头,直接看下一张。
⚠️ 箭头用 `hidden` 属性控显隐(见 preview.js 的 syncArrows)——
只有一个可预览项时不该出现(点了没反应比没有更糟)。
与 .pv 一样,自己声明了 display 的元素要藏就得补 [hidden] 规则。 */ ?>
<button type="button" class="pv-nav pv-prev" aria-label="上一张" hidden>‹</button>
<button type="button" class="pv-nav pv-next" aria-label="下一张" hidden>›</button>
</div>
<script src="<?= e(asset('/assets/js/app.js')) ?>"></script>
<?php /* 🔴 v32 第 3 条:浏览器通知(站内消息 + 日程到期提醒)。
⚠️ **只在已登录时加载** —— 登录页轮询会一直 302 去登录页,白跑。
同理也不在"免登录的导航展示页"上跑(那页没有会话)。
⚠️ 里面**不含**任何权限申请:权限必须由用户点小铃铛才要(见 notify.js 头部第 ①条)。 */ ?>
<?php if (\App\Auth::check()): ?>
<script>
window.FB_NOTIFY = {
poll: <?= json_encode(url('notifications/poll')) ?>,
list: <?= json_encode(url('notifications')) ?>,
icon: <?= json_encode(asset('/assets/icons/icon-96.png')) ?>,
canPush: <?= json_encode(\App\Auth::canWrite()) ?>
};
</script>
<script src="<?= e(asset('/assets/js/notify.js')) ?>" defer></script>
<?php endif; ?>
<?php /* v24 第 20 批:选人组件脚本改成**按需加载**(页面上真有 `.pp` 才拉)。
⚠️ 理由与 Cropper.js 同一套:`person_picker.js` 只在放选人组件的页面才有用,
而它并不小;每页都无条件带一份,等于让"没有选人组件"的页面白付流量。
⚠️ 判据用 `document.querySelector('.pp[data-pp]')`(组件根节点的真实标记),
**不是**含糊地找 "pp" 这两个字母(页面里 `pp` 太容易撞:分页、其它类名…)。
⚠️ DOM 必须已经解析到组件才判断 → 脚本放在 `</body>` 前(本文件本来就是),
所以这里能直接查;不需要 DOMContentLoaded 包一层。
⚠️ 无 JS 兜底不受影响:组件的 `<noscript>` 块本来就是给"脚本没跑"准备的。 */ ?>
<script>
(function () {
if (!document.querySelector('.pp[data-pp]')) { return; }
var s = document.createElement('script');
s.src = <?= json_encode(asset('/assets/js/person_picker.js')) ?>;
s.defer = true;
document.head.appendChild(s);
})();
</script>
<?php /* v27:笔记的分块编辑器同样**按需加载**(页面上真有 `#noteForm` 才拉)。
理由与 person_picker.js 完全同一套:它只在写笔记那一页有用,
每页都带一份等于让别的页面白付流量。判据用表单的真实 id(不是含糊找 "note")。 */ ?>
<script>
(function () {
if (!document.getElementById('noteForm')) { return; }
var s = document.createElement('script');
s.src = <?= json_encode(asset('/assets/js/note_editor.js')) ?>;
s.defer = true;
document.head.appendChild(s);
})();
</script>
<script src="<?= e(asset('/assets/js/filter.js')) ?>"></script>
<script src="<?= e(asset('/assets/js/preview.js')) ?>"></script>
</body>
</html>