日程表单的日历本下拉只列可写 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
+64
View File
@@ -203,6 +203,70 @@ final class Calendar
return ($cal['perm'] ?? '') === 'write';
}
/**
* 「日程表单里可以选来放的日历本」= 我会看到的本 里,**真正能往里放日程**的那些。
*
* 🔴🔴 v95(用户 2026-10-26):「网页端,添加日程,如果日历本是只读的,
* 例如别人共享给我只读,或者系统自带日历、观影、纪念日等,
* 就不要出现在日历本选框中,只出现那些可写的。」
*
* ⚠️ 为什么**不能**只写 `canWrite()` —— 这是本轮最容易写错的地方,
* 我一开始也以为 canWrite 就够了,实测(`_v95_dump_mine.py`)打脸:
*
* kind perm canWrite isReadonlyKind
* movie owner **Y** n ← 派生本,canWrite 放行!
* trip owner **Y** n ← 同上
* lunar owner **Y** **Y** ← 系统本,perm 是 owner 所以 canWrite 也放行!
* holiday owner **Y** **Y**
* custom/read read n n ← 只有"只读共享"这一类能被 canWrite 拦住
*
* ⇒ 三条判据缺一不可:
* ① `canWrite()` —— 拦掉「别人共享给我且只读」
* ② `!isReadonlyKind()` —— 拦掉系统只读本(农历/节假日/传统节日/节气)。
* 它们的 `owner_id` 就是我,所以 ① 恒真 —— 光靠 ① 一本也拦不住。
* ③ `kind === 'custom'` —— 拦掉观影/旅行派生本。
* 这两本**确实可写**(往里加一条 = 建一条观影/旅程记录,见
* `event_add` 里那两段分支),但它们不是"放日程的地方",
* 放在"新建日程"的下拉里只会让人建出别的东西 —— 用户点名要去掉。
*
* ⚠️ 别图省事写成 kind 白名单(`in_array($kind, [...])`):系统日历本每加一本
* 就要改一次、漏改就是"这本漏网"。② 用的是 `READONLY_KINDS` 常量,
* 新增系统本时只要登记在那里就自动生效。
*
* ⚠️ 这个函数是**唯一口径**:`calendar/index.php`、`index.php`(首页卡片)、
* API 都该走它。v82 那次首页自己写了一份 `array_filter(canWrite)`,
* 结果两个入口的下拉内容可以不一样 —— 一处改了另一处忘改。
*/
public static function writableForEvent(int $familyId, int $personId): array
{
return self::filterWritableForEvent(self::mine($familyId, $personId), $personId);
}
/**
* 上面那个的「已有一份 `mine()` 结果」版本 —— 给**手上已经有 `$cals`** 的调用方用
* (比如 `calendar/index.php` 的模板:那里拿到的是 `$cals`,没有 `$familyId`)。
*
* ⚠️ 判据必须**只有这一份**。把过滤逻辑在模板里再抄一遍,
* 就是 v82 那个"两个入口的下拉内容可以不一样"的老毛病。
*/
public static function filterWritableForEvent(array $cals, int $personId): array
{
$out = [];
foreach ($cals as $c) {
if (!self::canWrite($c, $personId)) {
continue; /* ① 只读共享 */
}
if (self::isReadonlyKind((string)($c['kind'] ?? ''))) {
continue; /* ② 系统只读本 */
}
if ((string)($c['kind'] ?? '') !== 'custom') {
continue; /* ③ 观影/旅行派生本 */
}
$out[] = $c;
}
return $out;
}
// ---------------- 我的日程显示偏好(v24 用户 2026-10-03 第 4、5 条)----------------
/** 三种视图(用户第 4 条:设置里可选默认) */