Files
FamilyBook/templates/calendar/_add_modal.php
T

298 lines
22 KiB
PHP
Raw Normal View History

<?php
/**
* 日程 · 「新建日程」弹框(v82 从 templates/calendar/index.php 抽出来的公共片段)。
*
* 用户原话(v82,2026-10-23):「日程,没有新建日程按钮……」
* 实测确认:用户说的是**首页日程卡片**上没有按钮(日程模块页的 `#calAddBtn` 一直在)。
* 要把「新建日程」搬上首页,最省事也最不会不同步的做法是:
* **把日程模块页那个弹框原样搬过来共用**,与 v81 的观影/纪念日同一套思路。
*
* 用户 2026-10-22 原话(v81 的动机,同样适用这里):
* 「就是把原来模块里弹的那个框,现在拿到这边来。目的就是为了在首页上就能
* 快速完成一定工作,而不用进到模块里面。」
*
* ─────────────────────────────────────────────────────────────────────────
* ⚠️⚠️ 抽公共片段的三条铁律(本项目踩过的,不是理论):
*
* ① **表单契约一个字不能改**:字段名 / CSRF / action(`calendar/event_add`)
* 全部照旧 —— 这个片段被两处 require,改一处就是两处一起变。
*
* ② 🔴🔴 **抽完之后立刻回部署脚本扫静态断言**:
* `_tooling/familybook_deploy.sh` 里有一批断言是拿**老文件名**去 grep 的
* (`id="calPicker"` / `id="cbDot"` / `class="cs-dot" style="background:`
* / `<span class="mv-lb">日历本</span>` / `$evMvStyle = true`)。
* 片段抽走后这些串**只在这个新文件里**,而断言还在查 `calendar/index.php`
* ⇒ 正向断言当场中止部署、负向断言恒真没牙齿(v78 原话:"部署当场中止,
* 看着像断言写错,其实是目标文件漂移")。
* ⇒ v82 已把这 5 条改成 `_cal_add_src` = 两个文件拼起来一起查。
*
* ③ **变量由调用方给全**,片段自己不许偷宿主的变量(本项目重复踩过)。
* 本片段需要的变量见文件末尾那段 `@var` 说明(含兜底逻辑)。
*
* ─────────────────────────────────────────────────────────────────────────
* @var bool $calCanNew 能不能新建(`Auth::canWrite() && $writable` 非空);片段内自己再兜一次
* @var array $calWritable 可写的日历本清单(含 id/name/color/kind)
* @var array $calCtx 上下文:ym / view / anchor / new_at / back / thisYm
* `back` 只认 'home' 或 ''('' = 回日程模块),服务端白名单取值
*/
/* ---------------------------------------------------------------- 兜底 */
/* 🔴🔴 v82 修正(2026-10-23):片段**自己读进来的变量一律加 `cal` 前缀**。
---------------------------------------------------------------------------
第一版是照模块页的老变量名写的(`$ym / $view / $anchor / $newAt / $writable`),
想着"沿用模块页的变量名最省事" —— 那是**整页**时代的写法,抽成片段就错了:
· require 与宿主**共享一个作用域** ⇒ 片段里那句
`$anchor = (string)($calCtx['anchor'] ?? date('Y-m-d'));`
直接改写了**模块页自己的 `$anchor`**;
· 今天两者恰好同值(模块页就是把它自己的值传进 `$calCtx` 的),
所以看不出问题 —— 但只要有一天首页传的 anchor 与模块页不同
(首页固定"今天",模块页是页面锚点),模块页后半页就会静默错位:
页头日期、翻页链接、时间网格的 `.tg-now` top 全跟着变。
· `$newAt` / `$writable` 同理,其中 `$writable` 还被首页 unset 过,
等于在首页里**把宿主的可写本清单干掉**(首页后面别的卡片要用)。
这 5 个名字(anchor / newAt / writable / view / ym)都在
`_tooling/_fb_fragment_scope_check.py` 的"宿主高频名"表里,是那个检查器
2026-10-03 就定下的规则(由来见它文件头:`unset($canWrite)` 把账本页炸成
一片 Warning,`php -l` 与 HTTP 200 全绿,只是页面排版坏了)。
⇒ 现在片段只碰 `$cal*`,宿主那套名字一个都不写。
⚠️ 别为了"读起来熟悉"改回去 —— 片段与宿主同名是这类 bug 的唯一入口。 */
$calCanNew = (bool)($calCanNew ?? false);
$calWritable = (array)($calWritable ?? []);
if (!$calCanNew || !$calWritable) {
return; /* 没得写就整块不输出(与模块页那个 if 等价) */
}
$calCtx = (array)($calCtx ?? []);
$calYm = (string)($calCtx['ym'] ?? date('Y-m'));
$calView = (string)($calCtx['view'] ?? 'month');
$calAnchor = (string)($calCtx['anchor'] ?? date('Y-m-d'));
$calNewAt = (string)($calCtx['new_at'] ?? '');
$calBack = (string)($calCtx['back'] ?? '');
$calThisYm = date('Y-m');
$calBooks = $calWritable; /* 片段内统一用这个短名遍历可写的本子 */
/* 🔴 v82:通用弹框开关(`assets/js/add_modal.js`)认的是 `data-fb-add-modal`。
⚠️ 必须**按来源**决定挂不挂:
· 首页内嵌(`$calBack === 'home'`)→ 挂上,由 `add_modal.js` 管开合;
· 日程模块页 → **不挂**,那页有自己的一套内联 JS(`calAddBtn` 的 onclick
+ 遮罩/✕/Esc 全在 `calendar/index.php` 底部),挂上会**绑两遍**:
点一次开两次、关两次(`add_modal.js` 的注释里写着同一个坑)。
⇒ 判据用 `$calBack`(首页那条路才会传 'home'),不要用"页面里有没有别的弹框"。 */
$fbAddModalAttr = ($calBack === 'home');
?>
<div class="cal-modal" id="calAddCard" role="dialog" aria-modal="true"
aria-label="新建日程" hidden<?= $fbAddModalAttr ? ' data-fb-add-modal' : '' ?>>
<div class="cal-modal-panel">
<div class="cal-modal-head">
<b>新建日程</b>
<button type="button" class="cal-modal-x" id="calAddCancel"
aria-label="关闭"<?= $fbAddModalAttr ? ' data-fb-add-close' : '' ?>>✕</button>
</div>
<form method="post" action="<?= e(url('calendar/event_add')) ?>"
enctype="multipart/form-data" id="calAddForm">
<?= csrf_field() ?>
<input type="hidden" name="ym" value="<?= e($calYm) ?>">
<input type="hidden" name="back_view" value="<?= e($calView) ?>">
<input type="hidden" name="back_anchor" value="<?= e($calAnchor) ?>">
<?php /* 🔴 v82:从**首页**那张弹框提交时带 `back=home`,无 JS 兜底回首页
(卡片上立刻能看到刚建的那条)—— 与 v81 观影/纪念日同一套约定。
⚠️ 服务端**白名单取值**(只认 'home'),绝不把 POST 字符串直接当跳转地址
(开放重定向)。见 index.php 的 `calendar/event_add`。
⚠️ 模块页那张不输出这个字段(`$calBack` 为 ''),行为与以前完全一致。 */ ?>
<?php if ($calBack === 'home'): ?>
<input type="hidden" name="back" value="home">
<?php endif; ?>
<div class="cal-modal-body">
<?php /* ⚠️ v44:`.mv-grid` 在这里就打开 —— 下面的「日历本」行
与「日期范围」字段块都要走观影式排版(标签右对齐 + 冒号)。
原来 `.mv-grid` 是包在字段块那一小段里的,日课本在它**外面**,
于是"日历本:下拉"落不到这套排版上(正是用户报的"不在一行")。 */ ?>
<div class="mv-grid">
<?php /* 🔴🔴 v44(用户 2026-10-12 第 1 条):「新建日程,放在哪本,改成日历本后面加冒号。
现在这句话和日历本下拉框还有前面的颜色,仍然不在一行,
我建议把颜色放在下拉框里面,就跟电影的影院选择一样,
让日历本这三个字和下拉框在一条线上。」
三件事:
① 标签改「日历本」(用户点名要这三个字),冒号由观影式排版的
`.mv-row > .mv-lb::after` 给(与观影/任务一致);
② **颜色不再是下拉框外的一个色块**,而是像"影院选择"那样
**画进下拉里**(每行左边一个该本子的色点)。
原生 `<option>` 画不出元素 ⇒ 换成**自定义下拉**
(复用全站的 `.cs` 组件:按钮 + 弹出列表 + 搜索),
真值仍写进隐藏的 `<input name="calendar_id">`(表单契约不变)。
③ 因为换成了一个自绘的下拉,三者天然在同一行 ——
不再有"标签一行、色块+下拉下一行"的错位。
⚠️ 无 JS 兜底:`.cs` 里**仍然保留**一个原生 `<select name="calendar_id">`
(视觉收起、但它是真的提交字段)—— JS 没跑时它就是那个下拉,
与 `_datetime.php` 的"隐藏 date 当值载体"是同一套思路
(v43 那条"没有盒子的元素定位无效"的教训:这里用 `position:absolute;
width:1px` 而非 `display:none`,保留可聚焦、可提交)。
⚠️ 组件 id 保持 `cinemaPicker/csBtn/csMenu/csDot/csText` **不合适**
(那是观影的编号)—— 用 `calPicker/cb*` 一套独立的,
免得将来两处互相 getElementById 抢。
⚠️ e2e / 断言里按 `#ce-cal` 定位的写法**必须继续成立**:
原生 select 的 id 保持 `ce-cal`(只是被视觉收起)。 */ ?>
<?php /* 🔴 v24 第 21 批 第 7 条:**默认必须选"我自己的普通日历本"**。
原来直接取 `$calBooks[0]` —— 而顺序是按 id,观影 / 旅行两本
通常排在最前面。于是用户打开"新建日程"、直接打字、点"加上",
建出来的其实是一条**观影记录**(或旅程)。
这不是"显示不对",是**建错东西**,而且事后很难发现
—— 用户以为日历里多了条日程,其实观影列表里多了一条记录。
所以:优先把自己建的 kind='custom' 本子排在前面当默认;
没有普通本时才退到第一个可写的本子。 */
$firstCal = null;
foreach ($calBooks as $c) {
if ((string)($c['kind'] ?? '') === 'custom') { $firstCal = $c; break; }
}
if ($firstCal === null) { $firstCal = $calBooks ? $calBooks[0] : null; }
/* ⚠️ 模板里**再兜一道**"快照":路由只会在**页面级**补一次本子,
而这张表单是页面渲染时定格的 —— 万一补本失败(DB 异常等),
这里至少还能看出"没有可写的普通本",不至于把"选中了观影本"
当成正常状态。 */
$hasPersonal = $firstCal !== null
&& (string)($firstCal['kind'] ?? '') === 'custom'; ?>
<div class="mv-row">
<span class="mv-lb">日历本</span>
<?php /* 🔴🔴 v93(用户 2026-10-25):「如果新建日程,日历本是拉不下来的,
是空的,好像只有一个"工作"什么的。」
真根因:这颗自绘下拉的**交互**原来内联在 `calendar/index.php` 底部,
而本片段被**首页**(`templates/home.php`)也 require 了一份 ⇒
首页那张弹框里按钮和菜单都在、点它却毫无反应,
只有服务端渲染出来的默认本名(「工作」)露在外面。
⇒ 交互已抽到全站脚本 `assets/js/cs_picker.js`,
它按容器上的 **`data-cs-picker`** 初始化。
⚠️ 这个属性**不能删**:删掉 = 首页那张弹框又变成"拉不下来",
而且症状与上次一模一样(HTML 全在、就是不响应)。
⚠️ 别改成"扫描所有 `.cs`"来省这个属性:观影表单的影院下拉
(`movies/form.php` 的 `#csBtn`)有一套自己的内联绑定,
两边都绑 = 点一次开两次。 */ ?>
<div class="cs cal-book-cs" id="calPicker" data-open="0" data-cs-picker>
<button type="button" class="cs-btn" id="cbBtn" aria-haspopup="listbox" aria-expanded="false">
<span class="cs-dot" id="cbDot"
style="background: <?= e((string)($firstCal['color'] ?? '#4A7C82')) ?>;"></span>
<span class="cs-text" id="cbText"><?= e((string)($firstCal['name'] ?? '未选择')) ?></span>
<span class="cs-caret" aria-hidden="true">▾</span>
</button>
<div class="cs-menu" id="cbMenu" hidden role="listbox">
<div class="cs-list" id="cbList">
<?php foreach ($calBooks as $c): $ro = \App\Calendar::isReadonlyKind((string)$c['kind']);
$isDef = $firstCal && (int)$c['id'] === (int)$firstCal['id']; ?>
<?php /* v24 第 21 批 第 7 条:纯派生的本子(农历/节假日/传统节日/节气)
列出来但**禁用** —— 直接不列的话,用户会以为"我的本子丢了";
列为可选项又会让他白选一次再吃一个错误提示。
禁用 + 一句 title 说明,是这个取舍里最不费解的一种。
观影 / 旅行**不禁用**:它们可写(加一条 = 建一条观影/旅程)。 */ ?>
<button type="button" class="cs-item<?= $ro ? ' is-disabled' : '' ?>"
role="option"<?= $ro ? ' disabled' : '' ?>
data-id="<?= (int)$c['id'] ?>"
data-color="<?= e((string)$c['color']) ?>"
data-text="<?= e((string)$c['name']) ?>"
<?= $ro ? 'title="系统自动生成,不能直接加日程"' : '' ?>>
<span class="cs-dot" style="background: <?= e((string)$c['color']) ?>;"></span>
<span><?= e((string)$c['name']) ?><?= $ro ? '(自动生成)' : '' ?></span>
</button>
<?php endforeach; ?>
</div>
<?php /* 一个都没匹配上时露出来(平时 hidden,不占高度) */ ?>
<div class="cs-empty" id="cbEmpty" hidden>没有匹配的日历本</div>
</div>
<?php /* ⚠️⚠️ 这是**真的提交字段**(不是"只给 JS 用"的影子):
自定义下拉只是它的外观;JS 没跑时它就是那个原生下拉。
视觉收起用 position:absolute + 1px(**不用 display:none**)——
后者会让表单校验时不可聚焦(v37 那次的坑)。
桌面无 JS 时它贴在按钮下方 1px 处、可点,仍能选。 */ ?>
<select class="select cal-book-native" id="ce-cal" name="calendar_id" aria-label="日历本">
<?php foreach ($calBooks as $c): $ro = \App\Calendar::isReadonlyKind((string)$c['kind']);
$isDef = $firstCal && (int)$c['id'] === (int)$firstCal['id']; ?>
<?php /* 🔴 v82:把 `kind` 也输出到 DOM(`data-kind`)。
为什么非要加:默认本挑错的后果是"建出一条观影记录",而
观影本 / 旅行本**都是可写的**(`$ro` 为 false),
光凭"不是禁用项 / 没有『(自动生成)』后缀"根本分辨不出它。
探针原本只能退化成"只要不是自动生成就算对" —— 那是**假绿**:
把默认本改成 `$calBooks[0]`(往往就是观影本)照样通过。
(2026-10-23 反向验证第 10 条当场抓到,见 _fb_b21_ui.py)
⇒ 有了 `data-kind`,判据就能写成"选中项的 kind 必须是 custom"。
⚠️ 别为了省一层缩进把它放进 `data-*` 之外的地方:
JS / 探针都按 `option.getAttribute('data-kind')` 读。 */ ?>
<option value="<?= (int)$c['id'] ?>"
data-color="<?= e((string)$c['color']) ?>"
data-kind="<?= e((string)($c['kind'] ?? '')) ?>"
<?= $isDef ? ' selected' : '' ?>
<?= $ro ? 'disabled title="系统自动生成,不能直接加日程"' : '' ?>><?= e((string)$c['name']) ?><?= $ro ? '(自动生成)' : '' ?></option>
<?php endforeach; ?>
</select>
<?php /* 兼容旧断言/旧 JS:外面那个独立色块曾经叫 ce-cal-dot。
现在颜色已经画进下拉里、不再需要它 —— 保留一个**隐藏**的同名元素,
免得旧探针 document.getElementById('ce-cal-dot').style 直接抛错。 */ ?>
<span class="cal-book-dot" id="ce-cal-dot" hidden
style="background: <?= e((string)($firstCal['color'] ?? '#4A7C82')) ?>;"></span>
</div>
</div>
<?php if (!$hasPersonal): ?>
<span class="tip" style="display:block;margin-top:6px">
⚠️ 你还没有**自己的日程本**,上面选的是一本"派生本"
(加进去会变成观影 / 旅程记录)。去
<a href="<?= e(url('calendar/calendars')) ?>">日历本</a> 建一本吧。
</span>
<?php endif; ?>
<?php /* v24 第 6 条:字段按 iCalendar / CalDAV 的 VEVENT 定(见 _event_fields.php)。
新建时「全天」默认**不勾**(用户原话)。开始时间默认:
带了 new_at 就用它(点日历的那一格),否则今天 09:00。
🔴 v43(用户 2026-10-11 第 5 条):「新建日程页面的排版还是不对,
也是一样的:把标题放在前面,后面加冒号,再跟上输入框。」
⇒ 用观影/行程那套 `.mv-grid`(标签 62px 右对齐 + CSS ::after 冒号 +
控件占满剩余宽度)包住字段块。
`$evMvStyle = true` 让字段块输出 `.mv-row` / `.mv-lb`
而不是 `.field` / `<label>`;编辑页不传,保持原两列。 */ ?>
<?php
// 点的位置优先;没点就"月视图当月 → 该月 1 号 / 本月 → 今天"的 09:00
$defDay = $calNewAt !== '' ? substr($calNewAt, 0, 10)
: (($calYm === $calThisYm ? date('Y-m-d') : $calYm . '-01'));
$defHM = $calNewAt !== '' && strlen($calNewAt) > 10
? substr($calNewAt, 11, 5) : '09:00';
$defEndH = sprintf('%02d', (min(23, (int)substr($defHM, 0, 2) + 1)));
$evFields = ['starts_at' => $defDay . ' ' . $defHM . ':00',
'ends_at' => $defDay . ' ' . $defEndH . ':' . substr($defHM, 3, 2) . ':00'];
$evAllDay = false;
/* ⚠️ v44:`.mv-grid` 容器**已经在上面日课本那一行之前打开了**
(日课本自己也要走观影式排版)—— 这里只 require 字段块,
不要再包一层,否则会出现嵌套的第二个 `.mv-grid`。 */
$evMvStyle = true; // 观影式排版(用户第 5 条)
require __DIR__ . '/_event_fields.php';
unset($evMvStyle);
?>
</div><?php /* /mv-grid */ ?>
</div><?php /* /cal-modal-body */ ?>
<div class="cal-modal-foot">
<span class="cal-modal-tip" id="calAddTip"></span>
<?php /* 🔴 v37 用户第 5 条:「"加上"和"取消"两个按钮在右上角,离得特别远,
根本看不到。应该把它们放在下方…」
真根因(真机量出来的,不是猜):
原来这里**多写了一个 `</div>`** —— `.cal-modal-panel` 被提前闭合、
跟着又被多关一层,于是 `<div class="cal-modal-foot">` 跑到了
**面板外面**(DOM 上它是 `.cal-modal` 的直接子元素)。
面板 `max-height` 一卡,底栏就被挤到面板下方 → 用户看不到。
另有 CSS 一层:`.cal-modal-panel > form` 原先不是 flex 列,
`.cal-modal-body{flex:1 1 auto}` 因此不起作用(两处都已修)。
⚠️ 提交键用 `form="calAddForm"` **显式关联**:多一层/少一层 DOM 的
意外都不会再让它失效(它本来就在 form 内部,但显式写更稳)。 */ ?>
<button class="btn btn-sm" type="submit" form="calAddForm" id="calAddSubmit">加上</button>
<button class="btn btn-ghost btn-sm" type="button" id="calAddCancel2"<?= $fbAddModalAttr ? ' data-fb-add-close' : '' ?>>取消</button>
</div>
</form>
</div>
</div>
<?php
/* 片段结束。调用方 `require` 完之后可以 unset 这些(模块页就是那么做的):
$firstCal / $hasPersonal / $defDay / $defHM / $defEndH / $evFields /
$evAllDay / $evMvStyle / $calBooks / $calYm / $calView / $calAnchor / $calNewAt
⚠️ 不 unset 也不报错,只是会把名字留在宿主的符号表里。
好在它们现在**全部带 `cal` 前缀**,与宿主模板(首页 / 日程页)的高频名
(`$view` / `$writable` / `$anchor` …)不会撞 —— 这正是加前缀的目的。
⚠️ `$evFields / $evAllDay / $evMvStyle` 是 `_event_fields.php` 的入参,
那几个名字由那个片段自己约定(它的 ALLOW 白名单里),不在本条讨论范围。 */