日程表单的日历本下拉只列可写 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
This commit is contained in:
2026-10-26 14:59:57 +08:00
parent daf5ca904e
commit a7fc7dcb0e
4 changed files with 105 additions and 23 deletions
+30 -17
View File
@@ -178,21 +178,27 @@ $fbAddModalAttr = ($calBack === 'home');
</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' : '' ?>
<?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']) ?>"
<?= $ro ? 'title="系统自动生成,不能直接加日程"' : '' ?>>
data-text="<?= e((string)$c['name']) ?>">
<span class="cs-dot" style="background: <?= e((string)$c['color']) ?>;"></span>
<span><?= e((string)$c['name']) ?><?= $ro ? '(自动生成)' : '' ?></span>
<span><?= e((string)$c['name']) ?></span>
</button>
<?php endforeach; ?>
</div>
@@ -205,8 +211,7 @@ $fbAddModalAttr = ($calBack === 'home');
后者会让表单校验时不可聚焦(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 foreach ($calBooks as $c): $isDef = $firstCal && (int)$c['id'] === (int)$firstCal['id']; ?>
<?php /* 🔴 v82:把 `kind` 也输出到 DOM(`data-kind`)。
为什么非要加:默认本挑错的后果是"建出一条观影记录",而
观影本 / 旅行本**都是可写的**(`$ro` 为 false),
@@ -216,12 +221,20 @@ $fbAddModalAttr = ($calBack === 'home');
(2026-10-23 反向验证第 10 条当场抓到,见 _fb_b21_ui.py)
⇒ 有了 `data-kind`,判据就能写成"选中项的 kind 必须是 custom"。
⚠️ 别为了省一层缩进把它放进 `data-*` 之外的地方:
JS / 探针都按 `option.getAttribute('data-kind')` 读。 */ ?>
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' : '' ?>
<?= $ro ? 'disabled title="系统自动生成,不能直接加日程"' : '' ?>><?= e((string)$c['name']) ?><?= $ro ? '(自动生成)' : '' ?></option>
<?= $isDef ? ' selected' : '' ?>><?= e((string)$c['name']) ?></option>
<?php endforeach; ?>
</select>
<?php /* 兼容旧断言/旧 JS:外面那个独立色块曾经叫 ce-cal-dot。