日程表单的日历本下拉只列可写 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
+6 -5
View File
@@ -976,12 +976,13 @@ switch ($r) {
不补的话首页这张弹框的默认本可能落到**观影本**上,
用户直接点"加上"就建出一条观影记录(v24 第 21 批真事故)。 */
Calendar::ensurePersonalBook($familyId, $me);
$agCals = Calendar::mine($familyId, $me);
/* 🔴 v95(用户 2026-10-26):口径统一走 `writableForEvent()`。
⚠️ 原来这里是首页自己写的 `array_filter(canWrite)`,与日程页
"看起来一样、实则不是同一份代码" —— 改一处忘另一处就漂移。
canWrite 拦不住观影/旅行派生本,也拦不住系统只读本
(它们的 perm 是 owner),详见 `Calendar::writableForEvent()`。 */
$homeAddModals['agenda'] = [
'writable' => array_values(array_filter(
$agCals,
static fn($c) => Calendar::canWrite($c, $me)
)),
'writable' => Calendar::writableForEvent($familyId, $me),
];
} elseif ($hcKey === 'contacts' && !isset($homeAddModals['contacts'])) {
/* 🔴 v82(用户 2026-10-23):「名册,也没有新增成员这个按钮」——