Files
FamilyBook/templates/calendar/_add_modal.php
T
laoyang a7fc7dcb0e 日程表单的日历本下拉只列可写 custom 本(#4)
用户原话(2026-10-26):「网页端,添加日程,如果日历本是只读的,例如别人
共享给我只读,或者系统自带日历、观影、纪念日等,就不要出现在日历本选框中,
只出现那些可写的。」

三条判据缺一不可(只做一条都修不好):
  ① canWrite()         —— 挡「别人共享给我且只读」。
     实测:只有这一类能被 canWrite 拦住。
  ② !isReadonlyKind()  —— 挡系统只读本(农历/节假日/传统节日/二十四节气)。
     它们的 owner_id 就是我 ⇒ canWrite() 恒真,光靠 ① 一本也拦不住。
  ③ kind === 'custom'  —— 挡观影/旅行的派生本。
     这两本**确实可写**(往里加一条 = 建一条观影/旅程记录),
     但不是"放日程的地方",放在新建日程下拉里只会让人建出别的东西。

- app/Calendar.php:新增 writableForEvent() / filterWritableForEvent(),
  作为**唯一口径**(日历页、首页卡片、API 都走它),
  避免 v82 那种"两个入口的下拉内容可以不一样"的老毛病。
  判据用 READONLY_KINDS 常量而不是 kind 白名单:新增系统本只要登记就自动生效。
- templates/calendar/index.php、index.php:改用上面的口径。
- templates/calendar/_add_modal.php:清掉恒假的 disabled 与「(自动生成)」
  死代码(前端已过滤,留着只会误导);保留 data-kind 给探针当锚点;
  按钮色点与文案改成**服务端就带出来**(无 JS 时也看得见颜色和本名)。

验证:
- 浏览器探针 28/0:含自建夹具(只读共享 / 可写共享 / 自己的)三类,
  与 PHP 侧独立重算的期望集逐项比对,真保存一次,
  以及**绕过前端直接 POST** —— 只读共享回「这本日历你没有写权限。」且库无新增;
  观影/旅行回 ok:true 且真的落 events 表(不进 calendar_events)。
- 反向验证 6/6 全抓住:退回 canWrite / 只看 kind / 不管 custom / 全滤空 /
  只读共享漏进下拉 / 拆掉 event_add 的 canWrite 闸门。
  最后一条注入时**只有 ⑪ 变红**,证明它测的是服务端拦截而非前端下拉。
- 全量流水线(--all)退出码 0,已部署并确认线上 4 个文件 php -l 全通过。

工单:#4
2026-10-26 14:59:57 +08:00

311 lines
23 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
/**
* 日程 · 「新建日程」弹框(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 /* 🔴 v95(用户 2026-10-26):「添加日程,如果日历本是只读的,
例如别人共享给我只读,或者系统自带日历,观影,纪念日等,
就不要出现在日历本选框中,只出现那些可写的。」
⇒ `$calBooks` 现在来自 `Calendar::writableForEvent()`,
已经**排掉了**只读共享本 / 系统只读本 / 观影旅行派生本
(三条判据见 `app/Calendar.php` 里 `filterWritableForEvent`
的注释:它们的 perm/canWrite/kind 各不相同,缺一条就漏一类)。
⚠️⚠️ 别再按 `isReadonlyKind()` 给每项加 `disabled` / 「(自动生成)」
后缀 —— 那是 v24 的「列出但禁用」方案,v95 已被用户明确否掉。
留着这段死代码的害处:后来人会以为"禁用的还在、只是没生效",
于是把 `disabled` 加回去,症状立刻复发(而且看着像"修好了")。
⚠️ 别改成"扫描所有 .cs"来省 `data-cs-picker`:观影表单的影院下拉
有自己的内联绑定,两边都绑 = 点一次开两次(见上方注释)。 */ ?>
<?php foreach ($calBooks as $c): $isDef = $firstCal && (int)$c['id'] === (int)$firstCal['id']; ?>
<button type="button" class="cs-item"
role="option"
data-id="<?= (int)$c['id'] ?>"
data-color="<?= e((string)$c['color']) ?>"
data-text="<?= e((string)$c['name']) ?>">
<span class="cs-dot" style="background: <?= e((string)$c['color']) ?>;"></span>
<span><?= e((string)$c['name']) ?></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): $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')` 读。
🔴 v95:`$calBooks` 里已经全是 custom 了(观影/旅行被
`writableForEvent()` 排掉)⇒ 这个 `data-kind` 现在是
**恒为 'custom' 的冗余信息**,但**不要删**:
① 探针 `_fb_v95_calbooks.py` 的 ③「每项 kind 都是 custom」
正是靠它判的 —— 删掉这条断言就无从下手;
② 万一将来有人放宽过滤(比如又想支持"往观影本加日程"),
这个字段能让探针立刻抓到"漏进来的是 movie"。
⇒ 留着一个恒真的字段给探针当锚点,比删掉它更安全。
同样的道理,下面**不再**输出 `disabled` / 「(自动生成)」。 */ ?>
<option value="<?= (int)$c['id'] ?>"
data-color="<?= e((string)$c['color']) ?>"
data-kind="<?= e((string)($c['kind'] ?? '')) ?>"
<?= $isDef ? ' selected' : '' ?>><?= e((string)$c['name']) ?></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 白名单里),不在本条讨论范围。 */