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 人时会误报"无法验证多选",已把实际候选数打出来。
This commit is contained in:
2026-10-10 11:01:22 +08:00
parent d83c94e127
commit 8661ab6475
9 changed files with 1003 additions and 36 deletions
+146
View File
@@ -5144,3 +5144,149 @@ h5.md-h, h6.md-h { font-size: 14px; }
@media (max-width: 560px) {
.cal-modal .form-grid { grid-template-columns: 1fr; }
}
/* ============================================================================
v38(2026-10-10) 观影"记一场电影"弹框:标签内联排版 + Ant Design 日期控件
----------------------------------------------------------------------------
用户原话(两条,一起改的):
「记录一场电影,这个弹框因为标题和下面的输入框都是两行的,所以导致弹框非常长。
能不能排排版,让前面的标题后面加个冒号,后面的输入框直接跟在它后面,
这样就变成一行。包括开始、结束时间、影院、同行的人、简介等等,能把它排成
一行的都排成一行,但是一定要注意对齐的问题,如果不对齐的话可能不好看。」
「我终于找到了一个我比较认可的日期选择插件
https://ant-design.antgroup.com/components/date-picker-cn 现在观影这个模块
进行测试吧…等这个模块测试完没问题了,再更新到其他的模块。」
============================================================================ */
/* ---- ① 「标签:控件」内联网格 ----
🔴 对齐靠的是**固定宽度的标签列**(62px + 右对齐),不是靠调间距 ——
标签长短差很多(片名/时间/影院/同行的人),但它们冒号后的控件左缘
必须落在同一条竖线上,看起来才整齐。
⚠️ 标签**自带冒号**(CSS 的 ::after 给)—— 让每个模板都手写":"迟早漏一个。
⚠️ `> .mv-lb` 而**不是** `.mv-row label`:后者会把同行人那排 checkbox 的
小标签一起卷进来(它们不是"字段标签")。 */
.mv-grid { display: flex; flex-direction: column; gap: 12px; }
.mv-row { display: flex; align-items: flex-start; gap: 10px; min-width: 0; }
.mv-row > .mv-lb {
flex: 0 0 62px; width: 62px; /* 固定宽 = 对齐的基准 */
text-align: right; padding-top: 9px; /* 与 40px 高输入框的文字基线对齐 */
font-size: 14px; font-weight: 600; color: var(--ink);
line-height: 1.4; word-break: keep-all;
}
.mv-row > .mv-lb::after { content: ':'; font-weight: 400; color: var(--muted); }
/* 必填星号:跟在标签文字后、冒号前 */
.mv-row > .mv-lb > i { font-style: normal; color: var(--red, #C0392B); margin-left: 1px; }
/* 控件区:占满剩余宽度(min-width:0 让它能被压缩,不会把标签挤出去) */
.mv-row > *:not(.mv-lb) { flex: 1 1 auto; min-width: 0; }
/* 片名那一行:输入框 + 「自动获取」按钮并排 */
.mv-row .db-title-row { display: flex; gap: 8px; align-items: center; }
.mv-row .db-title-row .input { flex: 1 1 auto; min-width: 0; }
.mv-row .db-title-row .btn { flex: 0 0 auto; }
/* 影院那一行:自定义下拉 + 「新建影院」并排 */
.mv-row .cinema-select-row { display: flex; gap: 8px; align-items: center; }
.mv-row .cinema-select-row .cs { flex: 1 1 auto; min-width: 0; }
/* 时间那一行是 `.mv-row.dt-range`:它既是"标签+控件"的行,又是**范围组**。
⚠️ 这两重身份会打架:`.dt-range.dt-cols` 是 `flex-direction: row`,
会把标签也当成"一个端点"排进去。⇒ 这里把它拉回"标签一行、两端在下",
标签仍占固定 62px(与上面的字段对齐)。 */
.mv-row.dt-range.dt-cols { flex-direction: row; flex-wrap: wrap; align-items: flex-start; }
.mv-row.dt-range.dt-cols > .dt-range-label { flex: 0 0 62px; width: 62px; }
/* 🔴 v38 对齐修正(真机量出来的 8px 偏差):
`.dt-cols` 是 `gap: 10px 18px`(行距 10 / **列距 18**),而普通 `.mv-row`
只有 `gap: 10px`。于是"标签 → 控件"这段在时间行是 18px、在别的行是 10px
⇒ 控件左缘差了 8px(实测 452 vs 444),一眼就看得出没对齐。
⇒ 时间行的列距拉回 10px,与别的行一致。 */
.mv-row.dt-range.dt-cols { column-gap: 10px; }
/* 两端并排(沿用 v37 的 .dt-cols 规则),并**从标签右边开始** */
.mv-row.dt-range.dt-cols > .dt-input {
flex: 1 1 0; min-width: 220px; margin-top: 2px;
}
.mv-row.dt-range .err { flex: 1 1 100%; margin-left: 72px; }
/* 海报 / 观影照片:**标签一行、内容另起一行**(不用 .mv-row)。
🔴 为什么不能跟上面的字段一样"标签在左、控件在右"(真机量出来的):
这两块的控件区里有好几个东西,而且 `_stage_drop.php` 会在最前面插一个
`.sr-only` 的 `<input>`(绝对定位、1px)。按 flex 排的话,那个 1px 的隐框
会占掉一整份 `flex:1 1 auto`,把真正的上传框**推到 130px 之外**
(实测:左缘 443 vs 隔壁 573 —— 一眼就是歪的)。
⇒ 纵向结构最省心:内容从**标签列右侧**(72px = 62 标签 + 10 间距)开始,
与上面各行的控件左缘**同一条竖线**。 */
.mv-grid-media { margin-top: 2px; gap: 14px; }
.mv-media { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.mv-media > .mv-lb { flex: 0 0 auto; width: auto; text-align: left; padding-top: 0; }
.mv-media-body { margin-left: 72px; display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.mv-media-body .photo-grid { margin-top: 0; }
/* ⚠️ `.sr-only` 是**绝对定位**的(见 .sr-only 的定义);它不该影响任何布局。
这里再钉一次"不参与 flex 分配",免得又被当成一个控件。 */
.mv-media-body > .sr-only,
.mv-row > .sr-only { position: absolute; flex: 0 0 0; }
/* ---- ② Ant Design RangePicker 的外观对齐 ----
目标是"看起来就是站内的一个输入框",而不是"嵌进来一个外来控件":
同高(40px)、同圆角、同描边、同纸色。 */
.dt-antd { flex: 1 1 auto; min-width: 0; }
.dt-antd .ant-picker {
width: 100%; height: 40px; padding: 0 11px;
border: 1px solid var(--line); border-radius: var(--radius-s);
background: var(--surface); box-shadow: none;
}
.dt-antd .ant-picker:hover { border-color: var(--brand-2); }
.dt-antd .ant-picker-focused { border-color: var(--brand); box-shadow: none; }
.dt-antd .ant-picker-input > input { font: inherit; font-size: 14px; color: var(--ink); }
/* 品牌色覆盖:让 antd 的选中/主色跟着模块色走(与 Semi 那次同一思路) */
.dt-antd .ant-picker-range-separator { color: var(--muted); }
.dt-antd .ant-picker-cell-in-view.ant-picker-cell-selected .ant-picker-cell-inner,
.dt-antd .ant-picker-cell-in-view.ant-picker-cell-range-start .ant-picker-cell-inner,
.dt-antd .ant-picker-cell-in-view.ant-picker-cell-range-end .ant-picker-cell-inner {
background: var(--brand) !important;
}
/* 中间的"区间底色"用模块色的浅版 */
.dt-antd .ant-picker-cell-in-view.ant-picker-cell-in-range::before,
.dt-antd .ant-picker-cell-in-view.ant-picker-cell-range-hover::before {
background: color-mix(in srgb, var(--brand) 12%, transparent) !important;
}
.dt-antd .ant-picker-time-panel-column > li.ant-picker-time-panel-cell-selected .ant-picker-time-panel-cell-inner {
background: color-mix(in srgb, var(--brand) 14%, transparent) !important;
}
.dt-antd .ant-btn-primary { background: var(--brand); border-color: var(--brand); }
.dt-antd .ant-btn-primary:hover { background: var(--brand-2) !important; border-color: var(--brand-2) !important; }
/* 面板:跟着纸色(antd 默认是纯白 + 冷灰边) */
.dt-antd .ant-picker-dropdown .ant-picker-panel-container {
border: 1px solid var(--line); box-shadow: var(--shadow-l, 0 10px 30px rgba(0,0,0,.14));
}
/* 面板整体跟着模块色(antd 的 CSS-in-JS 变量) */
.ant-picker-dropdown {
--ant-color-primary: var(--brand, #A84A28);
--ant-color-primary-hover: var(--brand-2, #C05F38);
}
/* ⚠️ 挂上 antd 之后:**原生那对下拉收起来**(它们仍然在 DOM 里当值载体)。
⚠️ 用 visibility+绝对定位而不是 display:none —— 见 v37 那次的教训:
display:none 的控件在表单校验时不可聚焦;这里虽然已摘掉 required,
但保持"可聚焦"更稳(免得将来再加必填时又踩同一个坑)。 */
.dt-range.is-antd > .dt-input {
position: absolute; width: 1px; height: 1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
border: 0; padding: 0; margin: -1px;
}
/* 窄屏:标签列收窄,别把输入框挤没了 */
@media (max-width: 560px) {
.mv-row > .mv-lb,
.mv-row.dt-range.dt-cols > .dt-range-label { flex: 0 0 52px; width: 52px; font-size: 13px; }
}
/* ---- v38 ②:能并排的都并排(用户:「能把它排成一行的都排成一行」)----
两处成对的字段:简介+备注、海报+观影照片。
⚠️ 用 **grid 两列**而不是 flex-basis:fr 单位保证两列**永远等宽**,
不会因为左边内容天然更宽(比如海报的图)就把右边挤窄 —— 那正是"不对齐"。
⚠️ 窄屏(≤560px)回到单列:手机上一行放两个多行文本框会窄到没法用。 */
.mv-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 20px; }
.mv-pair > .mv-row, .mv-pair > .mv-media { min-width: 0; }
/* 并排里每块自己的标签列窄一点(总宽只有一半了) */
.mv-pair > .mv-row > .mv-lb { flex: 0 0 52px; width: 52px; }
/* 并排里的媒体块:内容不再缩进 72px(本来就只占半列,再缩就没宽度了) */
.mv-pair > .mv-media > .mv-media-body { margin-left: 0; }
@media (max-width: 560px) {
.mv-pair { grid-template-columns: 1fr; }
.mv-pair > .mv-row > .mv-lb { flex: 0 0 62px; width: 62px; }
}
+128
View File
@@ -1285,6 +1285,21 @@
(function () {
var boxes = Array.prototype.slice.call(document.querySelectorAll('.dt-input[data-dt]'));
var hasMonth = !!document.querySelector('input[type="month"]');
/* 🔴🔴 v38(用户 2026-10-10):**Ant Design 的 RangePicker**。
用户原话:「我终于找到了一个我比较认可的日期选择插件
https://ant-design.antgroup.com/components/date-picker-cn 现在观影这个模块
进行测试吧,测试那个有开始、结束时间的。等这个模块测试完没问题了,
再更新到其他的模块。」
⇒ 页面出现 `[data-dt-antd]`(= 观影的起止那对)时才拉 antd 那三个文件,
**独立于** Semi 那套(Semi 只管"工作经历的年月")。
⚠️ 两套必须能**共存**:它们都吃同一个全局 React/ReactDOM(同版本,不会冲突),
但 antd/semi 各自的资源只在需要时才下。
⚠️ 顺序:**先**把原生下拉绑好(bindParts)、**再**升级成 antd ——
这样即使 antd 拉不动,用户手上的控件也是完整可用的。 */
var antdHosts = document.querySelectorAll('[data-dt-antd]');
bootAntd(antdHosts);
/* ⚠️ 只要还有 month 控件就得拉 Semi;纯 .dt-input 页面(绝大多数)不用拉。 */
if (!hasMonth) {
// 没有 month 控件:**只做绑定**,不下载任何外部资源。
@@ -1518,6 +1533,119 @@
group.classList.add('dt-cols');
}
/* ============================================================================
v38:Ant Design RangePicker 适配层(**只在观影模块试点**)
============================================================================
与自建下拉的分工:
· 自建下拉 = **唯一的值载体与兜底**。三个日期下拉照旧绑在隐藏的 `*_date` 上;
· antd 控件 = **额外**挂上去的一层 UI,选完把值写回那两个端点(走 writeDT)。
这样"antd 拉不动"时用户仍然能用自建下拉,不会出现"控件消失"。
🔴 实测出来的三条关键行为(见 _tooling/_antd_probe,15/0):
① UMD 需要全局 `React` / `ReactDOM` / `dayjs`,缺一个就挂不出来;
② 面板里的**时间列是内联可见的、还有「确定」按钮** —— 正是用户要的形态;
③ `onChange` 只在**两端都齐了**才触发,单端改动走 `onCalendarChange`。
⇒ 回写必须同时挂 `onCalendarChange`(实时)**和** `onChange`(确认),
否则"只填了开始时间就提交"会丢值。
⚠️ 这个适配层**不认识的页面不受影响**:没有 `[data-dt-antd]` 就直接返回。
============================================================================ */
function bootAntd(hosts) {
if (!hosts.length) { return; }
var meta = document.querySelector('meta[name="fb-antd"]');
if (!meta) { return; }
var conf;
try { conf = JSON.parse(meta.getAttribute('content')); } catch (e) { return; }
if (!conf || !conf.css || !conf.antd || !conf.dayjs) { return; }
var loading = null;
function ensure() {
if (window.antd && window.dayjs && window.React && window.ReactDOM) {
return Promise.resolve();
}
if (loading) { return loading; }
loadCss(conf.css);
// ⚠️ 顺序不能换:dayjs 是 antd 的依赖,antd 又从全局取 React/ReactDOM。
// React/ReactDOM 若已被 Semi 那套加载过,loadJs 会再下一次 —— 但它们是
// 幂等赋值(同一个 UMD),不会出事;先查一下能省掉那次下载。
var chain = Promise.resolve();
if (!window.React) { chain = chain.then(function () { return loadJs(conf.react || ''); }); }
if (!window.ReactDOM) { chain = chain.then(function () { return loadJs(conf.dom || ''); }); }
loading = chain
.then(function () { return window.dayjs ? null : loadJs(conf.dayjs); })
.then(function () { return window.antd ? null : loadJs(conf.antd); });
return loading;
}
var done = 0;
ensure().then(function () {
Array.prototype.forEach.call(hosts, function (box) {
if (mountAntd(box)) { done++; }
});
if (window.console && done) { console.info('AntD RangePicker 已挂上 ' + done + ' 处'); }
}).catch(function (err) {
// 拉不动就留在自建下拉上 —— 渐进增强的兜底,不弹任何打扰
if (window.console) { console.warn('AntD DatePicker 未启用:', err && err.message); }
});
}
/** 把**一对**端点升级成 antd RangePicker;成功返回 true。 */
function mountAntd(box) {
var group = box.closest ? box.closest('.dt-range') : null;
if (!group || group.getAttribute('data-antd-done')) { return false; }
var pair = Array.prototype.slice.call(group.querySelectorAll('.dt-input[data-dt]'));
if (pair.length !== 2) { return false; }
var a = group.querySelector('[data-dt-antd]');
if (!a) { return false; }
var b = null;
pair.forEach(function (p) { if (p !== a) { b = p; } });
if (!b) { return false; }
var host = document.createElement('span');
host.className = 'dt-antd';
a.parentNode.insertBefore(host, a);
function cur(el) {
var m = (readDT(el).trim() || '').match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})/);
return m ? m[1] + '-' + m[2] + '-' + m[3] + ' ' + m[4] + ':' + m[5] : '';
}
var v0 = cur(a), v1 = cur(b);
function toDayjs(str) { return str ? window.dayjs(str, 'YYYY-MM-DD HH:mm') : null; }
var last = ['', ''];
/* 回写:把 antd 给的两串写回两个端点(走既有的 writeDT → 隐藏四字段)。
⚠️ 两端都要写 —— 只写变化的那一端,另一端会被 writeDT('') 清空。 */
function writeBack(strs) {
var s0 = (strs && strs[0]) || '', s1 = (strs && strs[1]) || '';
if (s0 === last[0] && s1 === last[1]) { return; }
last = [s0, s1];
writeDT(a, s0);
writeDT(b, s1);
a.classList.toggle('has-value', !!s0);
b.classList.toggle('has-value', !!s1);
}
try {
ReactDOM.createRoot(host).render(React.createElement(antd.DatePicker.RangePicker, {
showTime: { format: 'HH:mm' },
format: 'YYYY-MM-DD HH:mm',
allowClear: true,
placeholder: ['开始时间', '结束时间'],
defaultValue: (v0 || v1) ? [toDayjs(v0 || v1), toDayjs(v1 || v0)] : undefined,
// ⚠️ onCalendarChange 是**单端也算**的那个(实测:只点开始它就响);
// onChange 只在两端齐了才响。两个都挂才不会丢值。
onCalendarChange: function (vals, strs) { writeBack(strs); },
onChange: function (vals, strs) { writeBack(strs); }
}));
} catch (e) {
if (window.console) { console.warn('antd RangePicker 挂载失败,留在自建下拉:', e && e.message); }
host.parentNode.removeChild(host);
return false;
}
group.classList.add('is-antd');
group.setAttribute('data-antd-done', '1');
return true;
}
function upgrade(box) {
if (box.getAttribute('data-semi-done')) { return; }
// 范围对的成员由 upgradeRange 统一处理
+353
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+253
View File
@@ -0,0 +1,253 @@
/* stylelint-disable */
html,
body {
width: 100%;
height: 100%;
}
input::-ms-clear,
input::-ms-reveal {
display: none;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: sans-serif;
line-height: 1.15;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
-ms-overflow-style: scrollbar;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
@-ms-viewport {
width: device-width;
}
body {
margin: 0;
}
[tabindex='-1']:focus {
outline: none;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 0;
margin-bottom: 0.5em;
font-weight: 500;
}
p {
margin-top: 0;
margin-bottom: 1em;
}
abbr[title],
abbr[data-original-title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline;
text-decoration: underline dotted;
border-bottom: 0;
cursor: help;
}
address {
margin-bottom: 1em;
font-style: normal;
line-height: inherit;
}
input[type='text'],
input[type='password'],
input[type='number'],
textarea {
-webkit-appearance: none;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1em;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 500;
}
dd {
margin-bottom: 0.5em;
margin-left: 0;
}
blockquote {
margin: 0 0 1em;
}
dfn {
font-style: italic;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 80%;
}
sub,
sup {
position: relative;
font-size: 75%;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
pre,
code,
kbd,
samp {
font-size: 1em;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
}
pre {
margin-top: 0;
margin-bottom: 1em;
overflow: auto;
}
figure {
margin: 0 0 1em;
}
img {
vertical-align: middle;
border-style: none;
}
a,
area,
button,
[role='button'],
input:not([type='range']),
label,
select,
summary,
textarea {
touch-action: manipulation;
}
table {
border-collapse: collapse;
}
caption {
padding-top: 0.75em;
padding-bottom: 0.3em;
text-align: left;
caption-side: bottom;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
color: inherit;
font-size: inherit;
font-family: inherit;
line-height: inherit;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
button,
html [type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
}
button::-moz-focus-inner,
[type='button']::-moz-focus-inner,
[type='reset']::-moz-focus-inner,
[type='submit']::-moz-focus-inner {
padding: 0;
border-style: none;
}
input[type='radio'],
input[type='checkbox'] {
box-sizing: border-box;
padding: 0;
}
input[type='date'],
input[type='time'],
input[type='datetime-local'],
input[type='month'] {
-webkit-appearance: listbox;
}
textarea {
overflow: auto;
resize: vertical;
}
fieldset {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
legend {
display: block;
width: 100%;
max-width: 100%;
margin-bottom: 0.5em;
padding: 0;
color: inherit;
font-size: 1.5em;
line-height: inherit;
white-space: normal;
}
progress {
vertical-align: baseline;
}
[type='number']::-webkit-inner-spin-button,
[type='number']::-webkit-outer-spin-button {
height: auto;
}
[type='search'] {
outline-offset: -2px;
-webkit-appearance: none;
}
[type='search']::-webkit-search-cancel-button,
[type='search']::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
summary {
display: list-item;
}
template {
display: none;
}
[hidden] {
display: none !important;
}
mark {
padding: 0.2em;
background-color: #feffe6;
}
+15 -2
View File
@@ -38,6 +38,10 @@
* $dtRange 'start' | 'end' —— 这对控件属于同一个起止范围(见下)
* $dtDateType 'select'(默认,年/月/日下拉)| 'native'(旧的原生 date 框)
* $dtYearFrom / $dtYearTo 年份下拉的范围(默认:今年 -120 ~ 今年 +10;生日建议 1900)
* $dtPlugin 🔴 v38 插件开关:'antd' = 由 app.js 把这两个端点升级成
* **Ant Design 的 RangePicker**(用户 2026-10-10 点名要的插件)。
* ⚠️ 只用在**起止成对**的两个端点上(见下),且**只在观影模块试点**;
* 其余页面留空即可(继续用自建下拉,行为一字不变)。
*/
$dtName = (string)($dtName ?? 'time');
$dtValue = $dtValue ?? null;
@@ -58,6 +62,13 @@ $dtYearTo = (int)($dtYearTo ?? ((int)date('Y') + 10));
留空 = 普通单点控件。 */
$dtRange = (string)($dtRange ?? '');
/* 🔴 v38:插件开关(见文件头)。只在**起止成对**的场景下有意义 ——
RangePicker 天生就是"一个控件管两端",单点控件挂它反而多一个"确定"。
⚠️ 即便声明了插件,**原生控件一个都不删**:JS 没跑 / 资源拉不动时,
用户看到的仍是那套自建下拉(渐进增强的老规矩)。 */
$dtPlugin = (string)($dtPlugin ?? '');
$dtUseAntd = ($dtPlugin === 'antd' && $dtRange !== '');
/* ⚠️⚠️ 临时变量**一律带 dt 前缀**,一个都不能漏:这里原来写成
`$t = strtotime(...)`,把宿主模板里表示「这条任务 / 这条记录」的 `$t`
覆盖成了 int —— 于是 include 之后凡是读 `$t['id']`、`$t['status']` 的地方
@@ -76,9 +87,11 @@ $dtSec = $dtTs ? (int)date('s', $dtTs) : 0;
时间改原生 select,**Semi 只剩「工作经历的年月选择」还在用**(那是另一处)。
.dt-semi 这个占位 span 保留 —— 老代码路径与 CSS 还在引用它,删了会牵连。 */
$dtCfg = ['name' => $dtName, 'seconds' => $dtSeconds, 'required' => $dtRequired,
'id' => $dtId, 'range' => $dtRange, 'parts' => $dtUseParts || null];
'id' => $dtId, 'range' => $dtRange, 'parts' => $dtUseParts || null,
'plugin' => $dtUseAntd ? 'antd' : null];
?>
<div class="dt-input<?= $dtRange !== '' ? ' dt-part' : '' ?><?= $dtUseParts ? ' dt-parts' : '' ?><?= $dtRange === 'start' ? ' is-pending' : '' ?>"
<?= $dtUseAntd ? 'data-dt-antd="1"' : '' ?>
data-dt="<?= e(json_encode($dtCfg, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>"
<?= $dtRequired ? 'data-dt-required="1"' : '' ?>>
<span class="dt-semi" aria-hidden="true"></span>
@@ -147,7 +160,7 @@ $dtCfg = ['name' => $dtName, 'seconds' => $dtSeconds, 'required' => $dtRequired,
</div>
<?php /* 用完即清,避免残留变量影响下一次 include */ unset(
$dtName, $dtValue, $dtRequired, $dtId, $dtSeconds, $dtCfg, $dtRange,
$dtDateType, $dtUseParts, $dtYearFrom, $dtYearTo,
$dtDateType, $dtUseParts, $dtYearFrom, $dtYearTo, $dtPlugin, $dtUseAntd,
$dtTs, $dtTsTmp, $dtDate, $dtY, $dtMo, $dtD, $dtDays,
$dtHour, $dtMin, $dtSec,
$dtH, $dtM, $dtS, $hv, $mv, $sv, $dtYy, $dtMm, $dtDd
+21
View File
@@ -66,6 +66,27 @@ $isLoggedIn = \App\Auth::check();
'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> 上换成本模块的色,
下面所有用到品牌色的地方(按钮、链接、页头高亮…)自动跟着变。
+74 -32
View File
@@ -28,7 +28,22 @@ $dtEnd = $dtFromOld('ends_at', $m['ends_at'] ?? null);
// 优先级:老输入 > ?cinema=(刚新建的)> 记录里的值。
$selCinema = (int)old('cinema_id', (int)($_GET['cinema'] ?? 0) ?: ($m['cinema_id'] ?? 0));
$curAttendees = array_map('intval', array_column($m['attendees'] ?? [], 'id'));
$selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
/* 🔴 v38(用户 2026-10-10):「同行的人,这里每个人添加的时候,应该默认带上自己,
也就是自己不用选择就应该已经选上了。」
⇒ **新增**时把"我"预置进已选列表;**编辑**时以库里的值为准
(用户可能就是自己改了,不能每次打开都把"我"塞回去)。
⚠️ `old()` 存在(= 刚才这次提交校验失败重画)时,尊重用户上一次的勾选 ——
否则他取消掉"自己"、填错别处再提交,会发现"自己"又回来了。 */
$meId = (int)\App\Auth::personId();
$hasOld = array_key_exists('attendees', (array)($_SESSION['_old'] ?? []));
if ($hasOld) {
$selAttendees = array_map('intval', (array)old('attendees', []));
} elseif ($isEdit) {
$selAttendees = $curAttendees;
} else {
// 新增:默认带上自己(名册里查得到才加 —— 免得给一个不存在的 id)
$selAttendees = $meId > 0 ? [$meId] : [];
}
?>
<?php /* 🔴 v37:本模板现在有两种用法 ——
① **整页**(`movies/edit` 的编辑页;以及 `movies/new` 的直接访问 / 校验失败重画);
@@ -87,9 +102,19 @@ $mvOpen = $mvModal && !$mvModalOnly;
<?php if ($mvModal): ?><div class="cal-modal-body"><?php endif; ?>
<div class="<?= $mvModal ? 'mv-form-inner' : 'card card-pad' ?>">
<div class="form-grid">
<div class="field full">
<label for="title">片名 *</label>
<?php /* 🔴🔴 v38(用户 2026-10-10):**标签内联 + 固定宽度对齐**。
用户原话:「记录一场电影,这个弹框因为标题和下面的输入框都是两行的,
所以导致弹框非常长。能不能排排版,让前面的标题后面加个冒号,后面的输入框
直接跟在它后面,这样就变成一行。包括开始、结束时间、影院、同行的人、简介等等,
能把它排成一行的都排成一行,但是一定要注意对齐的问题,如果不对齐的话可能不好看。」
⇒ 换成 `.mv-grid`:每行 "标签(固定 62px,右对齐) : 控件(占满剩余)"。
固定宽度是"对齐"的关键 —— 标签长短不一(片名/时间/影院/同行的人)时,
控件左缘仍然在**同一条竖线**上。
⚠️ 不用 `.form-grid`(它是 2 列 grid,本来就是"标题在上、控件在下"):
那一套是给普通页面表单用的,别为了弹框把它改掉。 */ ?>
<div class="mv-grid">
<div class="mv-row">
<label class="mv-lb" for="title">片名<i>*</i></label>
<div class="db-title-row">
<input class="input" id="title" name="title" value="<?= e(old('title', $m['title'] ?? '')) ?>"
required autocomplete="off">
@@ -105,21 +130,24 @@ $mvOpen = $mvModal && !$mvModalOnly;
<img id="dbPosterImg" alt="海报预览">
<span class="tip">海报已从豆瓣取回,保存后生效。</span>
</div>
<span class="tip">片名可从豆瓣自动补充:输入后从下拉里选,或点「自动获取」。</span>
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div>
<?php /* 用户 2026-10-04 第 7 条:起止是一个**范围**。用户 2026-10-09 第 2 次:
「前面是日期,后面是时和分,只记录到时和分就行了。时和分,是下拉的。」
⇒ 形态 = 日期框 + 时/分两个下拉(**没有秒**,见 app.js upgradeRange 的说明)。 */ ?>
<div class="field full dt-range">
<span class="dt-range-label">开始 / 结束时间(时:分)</span>
<div class="mv-row dt-range">
<span class="mv-lb dt-range-label">时间</span>
<?php /* v37:秒去掉、"散场时间可不填。"那句也删了(用户要求省空间)。
v38:`$dtPlugin = 'antd'` ⇒ app.js 把这对端点升级成
**Ant Design 的 RangePicker**(用户 2026-10-10 点名要的插件,
先在观影模块试点)。原生下拉**照旧在**,拉不动时就是它们。 */ ?>
<?php $dtName = 'starts_at'; $dtValue = $dtStart; $dtId = 'starts_at_date';
$dtRange = 'start';
$dtRange = 'start'; $dtPlugin = 'antd';
require __DIR__ . '/../_datetime.php'; ?>
<?php if (!empty($errors['starts_at'])): ?><span class="err"><?= e($errors['starts_at']) ?></span><?php endif; ?>
<?php $dtName = 'ends_at'; $dtValue = $dtEnd; $dtId = '';
$dtRange = 'end';
$dtRange = 'end'; $dtPlugin = 'antd';
require __DIR__ . '/../_datetime.php'; ?>
<?php if (!empty($errors['ends_at'])): ?><span class="err"><?= e($errors['ends_at']) ?></span><?php endif; ?>
</div>
@@ -133,8 +161,8 @@ $mvOpen = $mvModal && !$mvModalOnly;
→ 弹框里放"地方/名称/颜色"三个字段(影院管理页那张表单的核心),
提交时带 back=movies/new,服务端存完回到本页并自动选中新影院。
③ 下拉/弹框的样式与全站统一(宣纸底色、品牌色描边)。 */ ?>
<div class="field full">
<label for="cinema_id">影院</label>
<div class="mv-row">
<label class="mv-lb" for="cinema_id">影院</label>
<div class="cinema-select-row">
<div class="cs" id="cinemaPicker" data-open="0">
<button type="button" class="cs-btn" id="csBtn" aria-haspopup="listbox" aria-expanded="false">
@@ -160,11 +188,10 @@ $mvOpen = $mvModal && !$mvModalOnly;
</div>
<button type="button" class="btn btn-ghost btn-sm" id="cinemaNewBtn">新建影院</button>
</div>
<span class="tip">刚新建的影院会自动选中;下拉里的色点就是它在日历与列表里的颜色。</span>
</div>
<div class="field full">
<label>同行的人</label>
<div class="mv-row">
<span class="mv-lb">同行的人</span>
<?php /* v24 第 11 条:换成全局人员选择器(templates/_person_picker.php)。
原来这套是观影页私有的 .att-*,它的「过滤」其实一直是坏的 ——
.att-item 自己写了 display:flex,压过了浏览器的 [hidden]{display:none},
@@ -185,15 +212,20 @@ $mvOpen = $mvModal && !$mvModalOnly;
<?php endif; ?>
</div>
<div class="field full">
<label for="info">简介</label>
<textarea class="input" id="info" name="info" rows="6"
placeholder="剧情简介;点「自动获取」会把导演/主演/年份/类型等主要信息一起写进来"><?= e(old('info', $m['info'] ?? '')) ?></textarea>
</div>
<?php /* 🔴 v38 用户:「能把它排成一行的都排成一行」——
简介与备注都是多行文本、又都不长,并排成一对最省高度
(各自仍带自己的内联标签,左右两列**各自内部对齐**)。 */ ?>
<div class="mv-pair">
<div class="mv-row">
<label class="mv-lb" for="info">简介</label>
<textarea class="input" id="info" name="info" rows="3"
placeholder="剧情简介"><?= e(old('info', $m['info'] ?? '')) ?></textarea>
</div>
<div class="field full">
<label for="note">备注</label>
<textarea class="input" id="note" name="note" rows="2"><?= e(old('note', $m['note'] ?? '')) ?></textarea>
<div class="mv-row">
<label class="mv-lb" for="note">备注</label>
<textarea class="input" id="note" name="note" rows="3"><?= e(old('note', $m['note'] ?? '')) ?></textarea>
</div>
</div>
<?php
@@ -217,7 +249,16 @@ $mHasTicket = $isEdit && !empty($m['ticket_key']);
$mPhotos = $isEdit ? (array)($m['photos'] ?? []) : [];
$mDelForms = []; // 收集起来,在表单**外面**统一渲染
?>
<div class="form-grid" style="margin-top:4px">
<?php /* 🔴 v38 排版:海报 / 观影照片这两块不用 `.mv-row`。
原因(真机量出来的):它们的控件区里有**多个**东西 ——
· `_stage_drop.php` 会吐一个 `.sr-only` 的 `<input>`(绝对定位、1px)
排在 `.upfield` **前面**;
· 于是"谁才是控件"就说不清了 —— 按 `.mv-row` 的 flex 排,那个 1px 的
隐框会占掉一份 `flex:1 1 auto`,把后面的上传框**推到 130px 之外**
(实测左缘 443 vs 隔壁 573,一眼就是歪的)。
⇒ 这两块改成纵向:**标签一行、内容另起一行**,内容从**标签列右侧**开始
(`margin-left` 对齐那 72px),这样既不会歪,也不会被无关元素挤走。 */ ?>
<div class="mv-grid mv-pair mv-grid-media" style="margin-top:10px">
<?php /* ---- 观影照片(v35:不再分类,合并成一块)----
用户原话:「除了海报之外,票根、现场照片等等都不分类别,都只分一个类,
放在一块。…只有海报是单独的…其他的都放在一个模块就行,
@@ -228,8 +269,9 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
⚠️ **旧的 ticket_key 仍然要显示**(历史数据不能丢):编辑页把已有票根
列进下面这块的网格里(它没有 `event_photos` 行 id,所以只能"看"、
不能用照片的删除按钮删;要换就直接上传新的)。 */ ?>
<div class="field">
<label for="sd-poster">海报</label>
<div class="mv-media">
<span class="mv-lb">海报</span>
<div class="mv-media-body">
<?php if ($mHasPoster): ?>
<?php $mDelForms['poster'] = 'movies/media_del'; ?>
<div class="mv-slot">
@@ -249,13 +291,14 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
$sdMultiple = false;
$sdMsg = $mHasPoster ? '点击或拖动进行上传(换一张海报)' : '点击或拖动进行上传';
require __DIR__ . '/../_stage_drop.php'; ?>
<span class="tip"><?= $mHasPoster ? '选/拖一张新的会自动替换旧的那张。' : '还没有海报。' ?></span>
</div><?php /* /.mv-media-body */ ?>
</div>
</div>
<?php /* ---- 观影照片:票根 + 合影 + 其它相关图片,**全部放这一块** ---- */ ?>
<div class="field full">
<label for="sd-photo">观影照片</label>
<div class="mv-media">
<span class="mv-lb">观影照片</span>
<div class="mv-media-body">
<?php if ($mHasTicket || $mPhotos): ?>
<div class="photo-grid">
<?php if ($mHasTicket): /* 历史数据:旧票根(不在 event_photos 里,只展示) */ ?>
@@ -289,10 +332,9 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
<?php $sdName = 'photo[]'; $sdSlot = 'photo'; $sdAccept = 'image/*';
$sdMsg = '点击或拖动进行上传';
require __DIR__ . '/../_stage_drop.php'; ?>
<span class="tip"><?= $isEdit ? '观影照片都可以放这里(可多选);删掉的在保存后生效。'
: '观影照片都可以放这里(可多选);之后也能在详情页里逐张补传。' ?></span>
</div><?php /* /.field.full(观影照片) */ ?>
</div><?php /* /.form-grid */ ?>
</div><?php /* /.mv-media-body */ ?>
</div><?php /* /.mv-media */ ?>
</div><?php /* /.mv-grid */ ?>
</div><?php /* /.mv-form-inner */ ?>
<?php /* 🔴🔴 下面这一段**严格按分支配对**,别再把 </div> 与 <?php if ?> 混着写 ——
v37 前两版就是在这里把 `.mv-form-inner` / `.cal-modal-body` 关错了层数,
+12 -2
View File
@@ -125,10 +125,20 @@ if ($canWrite) {
if (btn) { btn.addEventListener('click', open); }
if (x) { x.addEventListener('click', close); }
if (c1) { c1.addEventListener('click', close); }
/* 点遮罩关、面板内不关;Esc 关 —— 与站内其它浮层同一套行为。 */
/* 点遮罩关、面板内不关;Esc 关 —— 与站内其它浮层同一套行为。
🔴🔴 v38:**antd 的日期面板自己也要吃 Esc**(按一次收日期面板)。
两个处理器都挂在 document 上,一次 Esc 会把"日期面板"和"弹框"**一起**关掉 ——
用户想关日期面板,结果整个弹框没了(探针当场抓到的)。
⇒ 判据加一条:**antd 面板开着时不吃 Esc**,让 antd 先收它自己的面板。 */
modal.addEventListener('click', function (e) { if (e.target === modal) { close(); } });
function antdPanelOpen() {
var d = document.querySelector('.ant-picker-dropdown');
return !!(d && !d.classList.contains('ant-picker-dropdown-hidden'));
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !modal.hidden) { close(); }
if (e.key !== 'Escape' || modal.hidden) { return; }
if (antdPanelOpen()) { return; } // 让 antd 先关它自己的面板
close();
});
<?php if (!empty($openModal)): ?>open();<?php endif; ?>
})();