Files
FamilyBook/templates/layout.php
T
laoyang f8b1b96fd7 v39 第 39 批:观影弹框(antd 中文化 + 影院搜索 + 简介备注各占一行 + 海报照片对齐)
用户原话(四条):
  ①「观影模块的时间控件,比如说时间呀,还有下面的确定按钮啊。现在显示的都是英文,
     应该给它设置成中文。」
  ②「影院那个地方应该有一个搜索的功能,下拉的地方上面有一个搜索筛选的功能。」
  ③「简介和备注不要放在一行,也就是说一个字段它就占一行。所以简介后面是一行,
     备注后面是一行。」
  ④「海报和观影照片为什么和上面的标题不一样呢?海报的字体和简介是一样的,也带一个冒号。
     然后把后面点击或拖动进行上传往后放,和简介后面的输入框保持一样大小。观影照片也是
     类似的,观影照片也是加粗。和简介的字体保持一致,不带冒号,后面放上点击或拖动进行上传。」

① antd 中文化 —— 三个条件缺一不可(缺任一条按钮就还是英文)
  · 🔴 **英文的真根因**:`antd.min.js` 这个产物**不含 locale 包**
    (实测 `window.antd.locales` 是 undefined)⇒ 只能用它内置的英文默认值。
    **换文件**才解决,不是加参数 ⇒ 改用 `antd-with-locales.min.js`
    (多带各语言包,全局多挂 `antd.locales`,`antd.locales.zh_CN` 即简体中文包)。
  · 🔴 dayjs 的中文 locale 是**单独一个文件**(核心包只有 en)⇒ 引 `dayjs-zh-cn.js`
    并调 `dayjs.locale('zh-cn')`(星期/月份由 dayjs 出)。
  · 🔴 必须用 `<ConfigProvider locale={zh_CN}>` 包住控件 —— antd 读的是**上下文里的**
    locale,不包的话**日历文字会变中文、按钮却仍是英文**(最容易误判成"已经中文化了")。
  · 独立探针 `_tooling/_antd_probe/{zh.html,zh_probe.py}` 9/0 把这三条钉死。
  · 删除不再引用的 `assets/vendor/antd/antd.min.js`(1.5MB 冗余,留着会让
    "两个 antd 哪个是真的"变成下一个坑);部署断言改为钉四资源 + ConfigProvider + zh-cn。

② 影院下拉加搜索筛选
  · 搜索框放在弹出列表**内部**(`.cs-search` 吸顶),打开下拉**即聚焦**;回车选第一项。
  · 🔴 `.cs-item[hidden] { display: none; }` **必须显式写** —— `.cs-item` 自己声明了
    `display:flex`,会盖过浏览器默认的 `[hidden]{display:none}` ⇒ 过滤"看着完全没生效"
    (项目第 11 批选人组件踩过同一个坑)。
  · 三个事件都 stopPropagation,否则冒到 document 的"点外面关"⇒ 一点搜索框下拉就没了。
  · 「不记录影院」恒保留(清空选择的出口);收起时清空搜索词(否则下次带着旧过滤)。

③ 简介 / 备注各占一行
  · 撤销 v38 的 `.mv-pair` 并排(半宽的多行文本框每行都换行,更难读);
    CSS 与模板里的并排容器**全部清干净**。

④ 海报 / 观影照片与简介对齐 —— 它们本来就不需要特殊规则
  · 用户看到的"更窄/不一样"有两个原因:① 海报那块被误包进 `.mv-grid.mv-pair`
    且**里面只有一个孩子** ⇒ grid 两列让它只占**半宽**;② 标签被一条
    "宽度自适应、左对齐"的规则覆盖掉了固定 62px 右对齐 + 冒号。
  · ⇒ 两块都改成普通 `.mv-row.mv-row-media`,标签用 `.mv-lb`(**继承**简介那套),
    内容包 `.mv-media-body`(占满剩余);观影照片标签加 `.no-colon`。
    实测:所有控件左缘 **424**、宽度 **624**,与简介**完全一致**。
  · 🔴🔴 **通识**:v38 的 `.mv-media`(手填 margin-left:72px)是**被一个取错元素的
    探针误导出来的** —— 它按文档序取"第一个非标签子元素",而 `_stage_drop.php` 吐的
    `.sr-only` 文件框是 `position:absolute`(**脱离文档流、不参与 flex**),却被算成
    "控件",于是得出"1px 隐框把上传框推走 130px"的**假结论**。
    ⇒ "对齐"类断言必须取直接子元素、且只取真正参与布局的那个;量到反常数字先怀疑探针。

验证(全部真机 + 反向验证):
  · _fb_b39_movie.py **39/0**(中文化三条 + 搜索框打开即聚焦/输入即过滤/无匹配空态/
    点搜索框不关下拉/回车选中/重开已清词 + 简介备注各占一行且占整行宽 +
    海报照片标签字重字号同简介/海报带冒号/照片不带/上传框左缘与宽度同简介);
  · _fb_b39_narrow.py **21/0**(宽 1440 / 平板 768 / 窄屏 390:不横向溢出、
    标签与控件仍同行、影院下拉不出视口、搜索框可用);
  · _fb_b39_falsify.py **14/0**(四处各自证伪成功:拿掉 ConfigProvider→按钮变英文、
    藏起搜索框→②红、塞回两列并排→③红、标签退回普通 span→④红;复原回全绿);
  · v38 两套按 v39 更新后 **36/0**(新增"已无 mv-pair / 主列 6 行");
    v38 的旧窄屏套件(断言"简介备注并排")已作废删除,由 v39 窄屏完全覆盖;
  · 全路由 e2e **1503/0**、日期控件真机 **53/0** + 反向 **7/0**、v37 两套、
    观影照片两套、b30 三件套、各批真机与 DAV 三套、部署静态断言空跑 —— 全绿;
  · 线上四个 antd 资源 HTTP **200 可达**;app.js / app.css / form.php / layout.php
    的 SHA256 与本地**逐字节一致**。

⚠️ 顺带修的探针/夹具问题(都不是页面 bug):
  · 夹具库重铺后 `cinemas` 是**空的** ⇒ v39 探针**自带播种**(造两家影院),
    不再依赖别的套件先跑过(与 v38 的"名册只有 1 人"同类:页面没问题,是夹具没数据);
  · 部署断言第一版按裸词 grep `mv-pair`,把注释里那句"已撤销 .mv-pair"当成回归
    ⇒ 改为只认 `class="...mv-pair..."` 这种**真的类名用法**;
  · v39 探针第一版判"简介/备注各占一行"看的是"是不是同一个 DOM 节点"——
    那个在**并排**时也是 false(并排仍是两个 .mv-row),连并排都抓不住
    (反向验证当场抓到 13/1)⇒ 改为按**垂直位置**判(top 相差 ≥20px)。
2026-10-10 12:42:43 +08:00

259 lines
16 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-with-locales 单文件约 1.8 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'),
/* 🔴 v39(用户 2026-10-10):「时间控件…还有下面的确定按钮,现在显示的都是英文,
应该给它设置成中文。」
⇒ 换成 **antd-with-locales**(同一个包的另一个产物,多带各语言包),
它在全局多挂一个 `antd.locales`,`antd.locales.zh_CN` 就是简体中文包
(含「确定 / 取消 / 此刻 / 今天」与星期/月份格式)。
⚠️ 普通 `antd.min.js` **不含** locales(实测 `antd.locales` 是 undefined)
—— 这是"按钮还是英文"的真正原因,换掉这个文件才解决。
⚠️ 它还依赖 dayjs 的中文 locale(星期/月份由 dayjs 出),见下面的 dayjsZh。 */
'antd' => asset('/assets/vendor/antd/antd-with-locales.min.js'),
'dayjs' => asset('/assets/vendor/antd/dayjs.min.js'),
/* dayjs 的核心包**只有 en**;中文 locale 是单独一个文件,
加载后调 `dayjs.locale('zh-cn')` 才生效(否则面板里星期还是 Su/Mo…)。 */
'dayjsZh' => asset('/assets/vendor/antd/dayjs-zh-cn.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>