日程表单的日历本下拉只列可写 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:
@@ -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 条:设置里可选默认) */
|
||||
|
||||
@@ -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):「名册,也没有新增成员这个按钮」——
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -26,7 +26,11 @@ $ts = strtotime($ym . '-01') ?: time();
|
||||
$prevYm = date('Y-m', strtotime('-1 month', $ts));
|
||||
$nextYm = date('Y-m', strtotime('+1 month', $ts));
|
||||
$thisYm = date('Y-m');
|
||||
$writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::canWrite($c, (int)\App\Auth::personId())));
|
||||
/* 🔴 v95(用户 2026-10-26):只列**能放日程**的本子。
|
||||
⚠️ 原来是 `array_filter($cals, canWrite)` —— 拦不住观影/旅行派生本(它们可写)
|
||||
和系统只读本(perm 是 owner,canWrite 恒真)。统一走 `Calendar::writableForEvent()`,
|
||||
详见那个方法的注释(里面记着实测的 perm/kind 对照表)。 */
|
||||
$writable = \App\Calendar::filterWritableForEvent($cals, (int)\App\Auth::personId());
|
||||
$visCals = array_map('intval', (array)($visCals ?? []));
|
||||
$hiddenCals = array_map('intval', (array)($hiddenCals ?? []));
|
||||
$newAt = (string)($newAt ?? '');
|
||||
|
||||
Reference in New Issue
Block a user