Files
FamilyBook/templates/calendar/calendars.php
T
laoyang 6ee5662854 v48:日历本共享面板提速(×日历本数 → 唯一一份)/ 点日历带入开始时间真修 / 去十字光标
用户 2026-10-14 三条。

① 日历本共享弹框「特别慢」—— 真根因 = 选人组件 ×日历本数
   用户原话:「设置日历本共享的时候,弹出框特别慢,好像又是 bitwarden 的问题,
              需要再彻查一下。」
   线上门禁诊断页实测(真实登录态):
       cals=12, roster=800, pp_components=12, html_kb=5876.5,
       json_attr_kb=1586.8, noscript_kb=4137.8, inputs=9657, labels=9593
   服务端只花 ~70ms ⇒ **慢在浏览器侧**。本地夹具造出同规模复现:
       首屏 4086ms / 整页 9.3MB / noscript 文本 6.7MB。
   真根因:calendars.php **每本日历**都内联一整套 _person_picker,每个组件把
   **全部 800 人**化成 1 份 data-pp-people JSON + 1 份 <noscript> 兜底 ⇒
   12 本 = 12 份 ⇒ 近万个表单控件,浏览器/密码管理器逐个扫描。
   ⚠️ **不是 Bitwarden 独有**(用户怀疑它)——任何遍历 input / 读整页 HTML 的扩展都会中招。
   修法:与账目页 #ledPanels / 名册 #rosterMenus 同一套(同类问题第 3 次)——
   行上只留 [data-cal-share-open] 按钮,真表单全页唯一一份 #calSharePanel。
   线上复验:html_kb 5876.5 → **526.6**、pp_components 12 → **1**、noscript 6.7MB → 0.34MB。

② 点日历带入开始时间失效 —— 真根因是 window.fbSetDT 被提前 return 吞掉
   用户原话:「之前这个功能是有的,后来我们改了时间控件之后,这个功能就没有了。」
   两层真根因(都在 assets/js/app.js):
     · fbSetDT 定义在 `if (!hasMonth) { … return; }` **之后**(IIFE 尾部)——
       日程页首屏没有 input[type=month] ⇒ 直接 return ⇒ 尾部赋值**永不执行**
       ⇒ setStart() 里 typeof window.fbSetDT === 'function' 为假。(与 v42 修
       fbBootAntd 是同一家族:对外接口写在提前 return 之后 ⇒ 静默失效。)
     · bindParts() 初始化是"以三个下拉为准"(rebuildDays(true); sync();),而
       __fbSyncParts 这个**赋值**写在它们之后 ⇒ 点击带入的 dt-date 被旧下拉盖回。
       真浏览器调用栈实测:set date=2026-10-10(setStart)紧跟 set 2026-10-14(sync←bindParts)。
     · 第三层:group.__fbRemount 是 v35 的空壳,且 fbSetDT 查找被 data-semi-range-done
       门住 —— 日程「结束」默认隐藏 ⇒ upgradeRange 早退、永不设该标记 ⇒
       **可见的 antd 控件永不重绘**(隐藏字段对了、用户看到的还是旧值)。
   修法:① fbSetDT 提到所有 return 之前;② bindParts 初始化改成**以权威字段 date
   为准**(box.__fbSyncParts(); sync();,且赋值顺序提前);③ antd RangePicker/单点
   控件改**受控**(value+状态)、__fbRemount 接成"重读原生值+重绘"、查找不再要求
   data-semi-range-done。真浏览器实测:**可见控件**显示 2026-10-10 09:00 / 周视图 15:00。

③ 去掉十字光标
   用户:「现在鼠标放在视图上时,变成了一个十字。我感觉这个没必要。」
   删 app.css 的 cursor:crosshair 规则;`.rt-writable` 类名**保留**(它是"能不能写"
   的 DOM 标记位,_fb_batch_e.py 断言依赖它)。点击建日程的能力**一字不动**。

验证(新增 3 个套件 + 1 个反向验证):
 · _fb_v48_cal.py(12 条,含"**可见的** antd 控件显示值"两条)
 · _fb_v48_picker.py(9 条,造 12 本 + 800 人规模,量首屏耗时/整页体积/picker 数)
 · _fb_v48_share_e2e.py(4 条,共享真落库 + 权限正确)
 · _b48_falsify.py(**6 个注入 / 13 条断言全抓**,含"antd 改回非受控"、
   "不接 __fbRemount"、"重现第二份选人组件")
 · 全量 e2e 1510/0、vCard 40/0、几何 428/0、v38/v39/v40/v47 反向验证全绿
 · 全流水线 **PUSH_RC=0**;线上四文件 md5 与本地逐字节一致;
   线上门禁诊断复验三条修复全部 YES,临时诊断页已删并复验 404

顺带修掉的**工具链自身缺陷**(详见 pitfalls.md):
 · familybook_deploy.sh 有 8 处 `_code_only … | grep -q`,在 `set -o pipefail` 下
   会因 SIGPIPE(141) **假红**(本轮缩短了 calendars.php 上半段就触发)。
   新增 `_code_has <file> <str>`(先落盘再 grep,不走管道)全部换掉。
 · v40 那条"数据丢失级"静态守卫盯的是老字面量 `rebuildDays(true)` —— 改成
   接受两种安全形式,行为层最终判据仍在 _fb_b40_movie.py(全绿)。
 · 更新 4 个反向验证脚本里因本次重构而失配的锚点(b38/b39/b40 + 新增 b48)。
2026-10-14 14:30:37 +08:00

353 lines
18 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
/**
* 日历本管理(v12):建本 / 改名 / 换色 / 删除 / 共享给别人(只读或可写)。
*
* ⚠️ 「观影」「旅行」两本是系统预置的,不能改名也不能删 ——
* 它们的日程是从观影模块与旅程模块实时汇总出来的,删掉就再也回不来了
* (Calendar::delete 里也只删 kind='custom',这里是同一道栅栏的界面侧)。
*
* @param array $cals 我自己的日历本(kind 任意)
* @param array $shared 别人共享给我的
* @param array $people 名册(共享下拉用)
*/
$isEditing = !empty($editing);
$canWrite = \App\Auth::canWrite();
?>
<div class="page-head">
<h1>日历本</h1>
<span class="sub">共 <?= count($cals) ?> 本<?= $shared ? ',别人共享给我 ' . count($shared) . ' 本' : '' ?></span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('calendar')) ?>">返回日程</a>
</div>
</div>
<?php if (!$canWrite): ?>
<div class="card card-pad empty"><b>只读账号</b>访客只能查看日历本,不能新建或修改。</div>
<?php else: ?>
<div class="card card-pad">
<h2 class="cinema-h2"><?= $isEditing ? '编辑日历本' : '新建日历本' ?></h2>
<form method="post" action="<?= e(url($isEditing ? 'calendar/cal_update' : 'calendar/cal_add')) ?>">
<?= csrf_field() ?>
<?php if ($isEditing): ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<div class="field">
<label for="cb-name">名字 *</label>
<input class="input" id="cb-name" name="name" required
value="<?= e(old('name', $editing['name'] ?? '')) ?>" placeholder="如:体检 / 值日">
</div>
<div class="field">
<label for="cb-color">颜色</label>
<div class="color-row">
<input type="color" id="cb-color" name="color" class="color-picker"
value="<?= e(old('color', $editing['color'] ?? '#4A7C82')) ?>">
<span class="color-hex"><?= e(old('color', $editing['color'] ?? '#4A7C82')) ?></span>
<span class="preset-palette">
<?php foreach (\App\Calendar::PRESET_CAL_COLORS as $pc): ?>
<span class="preset-color" data-color="<?= $pc ?>" style="background: <?= $pc ?>;" title="<?= $pc ?>"></span>
<?php endforeach; ?>
</span>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $isEditing ? '保存' : '新建' ?></button>
<?php if ($isEditing): ?>
<a class="btn btn-ghost" href="<?= e(url('calendar/calendars')) ?>">取消</a>
<?php endif; ?>
</div>
</form>
</div>
<?php endif; ?>
<div class="card card-pad">
<h2 class="cinema-h2">我自己的</h2>
<?php if (!$cals): ?>
<p class="empty-hint">还没有日历本。</p>
<?php else: ?>
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>名字</th><th>颜色</th><th>共享给</th><?php if ($canWrite): ?><th>操作</th><?php endif; ?></tr>
</thead>
<tbody>
<?php foreach ($cals as $c): ?>
<tr>
<td>
<b><?= e((string)$c['name']) ?></b>
<?php if (($c['kind'] ?? '') !== 'custom'): ?>
<span class="node-badge">系统预置</span>
<?php endif; ?>
</td>
<td>
<span class="color-preview" style="background: <?= e((string)$c['color']) ?>;"></span>
<span class="mono"><?= e((string)$c['color']) ?></span>
</td>
<td>
<?php $sh = $shares[(int)$c['id']] ?? []; ?>
<?php if (!$sh): ?>
<span class="link-quiet">不共享</span>
<?php else: ?>
<div class="trip-people">
<?php foreach ($sh as $s): ?>
<?= person_chip($s) ?>
<span class="node-badge"><?= ($s['perm'] ?? '') === 'write' ? '可写' : '只读' ?></span>
<?php /* 取消共享:**保留**(每个只是一张小表单,不含选人组件,
十几个也不需要优化)。原来它与"选人"一齐被关在
<details> 里,现在 <details> 撤了,这里就地给个按钮。 */ ?>
<?php if ($canWrite): ?>
<form class="inline-form" method="post" action="<?= e(url('calendar/cal_unshare')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<input type="hidden" name="person_id" value="<?= (int)$s['person_id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
title="取消共享给 <?= e(person_name($s)) ?>">取消</button>
</form>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($canWrite && $people): ?>
<?php /* 🔴🔴 v48(用户 2026-10-14 第 1 条:「设置日历本共享的时候,
弹出框特别慢,好像又是 bitwarden 的问题,需要再彻查一下。」)
真根因(线上门禁诊断页实测,不是猜):
本页有 **12 本日历**,原来**每一本都内联一整套选人组件**,
而每个组件都把**全部 800 人**化成一份 JSON 与一份 noscript 兜底:
html_kb=5876.5, json_attr_kb=1586.8, noscript_kb=4137.8,
inputs=9657, labels=9593, pp_components=12
⇒ 整页 **5.9 MB / 近万个表单控件**,浏览器 + 密码管理器逐个扫描
⇒ "弹出框特别慢"。本地夹具造出同规模后首屏 **4.1 秒 / 9.3 MB**。
⚠️ 这**不是 Bitwarden 独有的问题**:任何遍历 `input` / 读整页 HTML
的扩展都会中招,Bitwarden 只是最典型的那个。
修法与**账目页 #ledPanels / 名册 #rosterMenus 同一套**(同一类问题
在这个项目里已经出现过两次,都是"把共享面板收成一份"解决的):
行上只留一个**按钮**(带 data-* 标明是哪本日历),
真正的选人表单全页**只有一份**,放在页尾 #calSharePanel,
点按钮时把"日历 id / 名字"灌进去。 */ ?>
<button type="button" class="link-quiet btn-inline"
data-cal-share-open
data-cal-id="<?= (int)$c['id'] ?>"
data-cal-name="<?= e((string)$c['name']) ?>">共享设置</button>
<?php endif; ?>
</td>
<?php if ($canWrite): ?>
<td>
<div class="actions">
<?php /* v24 第 7 条:每本都能导出 .ics(预置本也能 —— 导的是派生出来的内容)。 */ ?>
<a class="btn btn-ghost btn-sm"
href="<?= e(url('calendar/export', ['id' => (int)$c['id']])) ?>">导出 ics</a>
<?php if (($c['kind'] ?? '') === 'custom'): ?>
<a class="btn btn-ghost btn-sm"
href="<?= e(url('calendar/calendars', ['edit' => (int)$c['id']])) ?>">编辑</a>
<form method="post" action="<?= e(url('calendar/cal_del')) ?>"
onsubmit="return confirm('删除「<?= e((string)$c['name']) ?>」?本子里的日程会一起删掉。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<button class="btn btn-danger btn-sm" type="submit">删除</button>
</form>
<?php else: ?>
<span class="link-quiet">不可改</span>
<?php endif; ?>
</div>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php if ($canWrite): ?>
<?php /* v24 第 7 条:把一个 .ics 文件里的日程整批导进某一本。
只能导进自定义本 —— 观影/旅行两本是派生的,塞进去就成了孤儿日程
(日历里有、观影列表里没有)。 */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
导入 ics
<span class="hint">把别的日历导出的 .ics 文件整批装进来</span>
</h2>
<p class="small muted" style="margin:0 0 16px">
同一个文件导两次也<b>不会重复</b>:每条日程有个全球唯一的 UID,
已经导过的会被更新,而不是再插一条。
</p>
<form method="post" action="<?= e(url('calendar/import')) ?>" enctype="multipart/form-data">
<?= csrf_field() ?>
<div class="form-grid">
<div class="field">
<label for="im-cal">导进哪一本 *</label>
<select class="select" id="im-cal" name="calendar_id">
<?php foreach ($cals as $c): ?>
<?php if (($c['kind'] ?? '') !== 'custom') { continue; } ?>
<option value="<?= (int)$c['id'] ?>"><?= e((string)$c['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label for="im-file">.ics 文件 *</label>
<?php /* 🔴 v30 用户第 1 条:$sdStage = false —— 导入型上传**不走暂存**。
用户原话:「这个文件导入之后,以后应该就不会再访问它…把它上传到服务器上
个临时目录里面,把数据读出来之后,写入到我们的日程的数据库表中,
然后再把这个文件删除就可以了。」
⇒ 文件走 PHP 自带的临时文件($_FILES['ics']['tmp_name']),
路由读完即弃;不落存储、不建附件行、不进 OSS。
⚠️ 走暂存模式时 `<input>.files` 始终是空的 → 带 required 的提交会被
浏览器原生校验拦下,弹「请选择一个文本。」(用户报的就是这句)。 */
$sdName = 'ics'; $sdId = 'im-file'; $sdAccept = '.ics,text/calendar';
$sdMultiple = false; $sdRequired = true; $sdStage = false;
require __DIR__ . '/../_stage_drop.php'; ?>
</div>
</div>
<div class="btn-row">
<button class="btn btn-sm" type="submit">导入</button>
</div>
</form>
</div>
<?php endif; ?>
<?php /* v24 第 7 条后半:CalDAV —— 把我的日历本订阅到手机/别的日历程序里。
v24 第 22 批:口令改成**通用 DAV 口令**(日历 + 通讯录共用一把),
管理入口也搬到了「个人设置 → DAV 同步」—— 这里只留地址与一个指路按钮。
⚠️ 这一页**不再有**生成/撤销表单:同一个口令在两处各放一套按钮,
迟早出现"在这里重置了、那页还显示已配置"的对不上(而且密码类的东西
有两个入口本身就是坏味道)。 */ ?>
<div class="card card-pad" id="caldav">
<h2 class="cinema-h2">
CalDAV 订阅
<span class="hint">在手机日历 / DAVx⁵ / Thunderbird 里订阅你的日历本</span>
</h2>
<p class="small muted" style="margin:0 0 12px">
填下面这个地址 + 你的<b>姓名或手机号</b> + <b>DAV 口令</b>,就能把「我的日历本」
同步到别的日历程序里。这是<b>只读</b>订阅的 —— 在手机上改日程请回到本站来改。
<br>口令与登录密码是<b>分开的</b>,而且<b>通讯录同步也用同一把</b>(不用配两遍)。
</p>
<div class="field">
<label>服务器地址</label>
<input class="input mono" readonly
value="<?= e(rtrim((string)fb_config('base_url', ''), '/') . '/dav/') ?>">
<span class="tip">在日历客户端里填这一条即可,会列出你所有的日历本。</span>
</div>
<div class="btn-row">
<a class="btn btn-sm" href="<?= e(url('settings/me', [], 'dav')) ?>">
<?= !empty($cdHas) ? 'DAV 口令已配置(去管理 / 重置)' : '去生成 DAV 口令' ?>
</a>
<span class="node-badge">口令在「个人设置 → DAV 同步」里管理</span>
</div>
</div>
<?php if ($shared): ?>
<div class="card card-pad">
<h2 class="cinema-h2">别人共享给我的</h2>
<div class="table-wrap">
<table class="table">
<thead><tr><th>名字</th><th>谁的</th><th>我的权限</th></tr></thead>
<tbody>
<?php foreach ($shared as $c): ?>
<tr>
<td><b><?= e((string)$c['name']) ?></b></td>
<?php /* 共享过来的行里"人"的字段带着前缀(owner_name / owner_nick),
不是 person_name() 直接认的 name / my_nickname —— 在这里适配一次。 */ ?>
<td><?= e(person_name(['name' => $c['owner_name'] ?? '', 'my_nickname' => $c['owner_nick'] ?? ''])) ?></td>
<td><span class="node-badge"><?= ($c['perm'] ?? '') === 'write' ? '可写' : '只读' ?></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php /* ==================== 共享设置:全页**唯一**一份(v48)====================
与账目页 #ledPanels、名册 #rosterMenus 同一套思路:行上只留一个按钮,
真正的选人表单集中在这里。
🔴 为什么必须只有一份(用户 2026-10-14 第 1 条「弹出框特别慢」的真根因):
原来**每本日历**都内联一套 `_person_picker`,而每个组件都把全部 800 人
化成 JSON + `<noscript>` 兜底。12 本 ⇒ 整页 5.9 MB / 近万个 input
⇒ 浏览器与密码管理器逐个扫描,慢得能感觉到。
收成一份之后:**一整套选人组件**(1 份 JSON + 1 份 noscript),
与日历本数无关 —— 再多本日历页也不会再变大。
⚠️ 选人组件是**单选**(`$ppMode='single'`):行上按钮被点开时,用
`window.ppSetSelected('person_id', 0, panel)` 把它重置成"未选",
再让用户挑 —— 否则上一次挑的人会留在表单里(看着像"已经选好了")。 */ ?>
<?php if ($canWrite && $people): ?>
<div class="cal-modal" id="calSharePanel" hidden role="dialog" aria-modal="true"
aria-label="共享日历本">
<div class="cal-modal-panel" style="max-width:520px">
<div class="cal-modal-head">
<b>共享「<span class="cal-share-title"></span>」给谁</b>
<button type="button" class="cal-modal-x" data-cal-share-close aria-label="关闭">✕</button>
</div>
<form method="post" action="<?= e(url('calendar/cal_share')) ?>" class="cal-modal-body"
id="calShareForm">
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<div class="dt-range-label">选择要共享给谁</div>
<?php
$ppName = 'person_id';
$ppPeople = array_values(array_filter($people,
fn($p) => (int)$p['id'] !== (int)\App\Auth::personId()));
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择要共享给谁';
$ppTitle = '共享给谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
<div class="field" style="margin-top:12px">
<label for="cal-share-perm">权限</label>
<select class="select" name="perm" id="cal-share-perm">
<option value="read">只读</option>
<option value="write">可写</option>
</select>
</div>
</form>
<div class="cal-modal-foot">
<?php /* ⚠️ 用 `form="calShareForm"` **显式关联**(表单在 .cal-modal-body 里、
按钮在 .cal-modal-foot 里,不是同一个父节点)。原来写成 `form=""` 是
**无效值**(空串不等于任何表单 id),点了不提交 —— 与 v37 日程弹框
那次"按钮看不到"同一类 DOM 结构坑。id 由下面的脚本赋上(避免与别处撞名)。 */ ?>
<button class="btn btn-sm" type="submit" form="calShareForm">共享</button>
<button class="btn btn-ghost btn-sm" type="button" data-cal-share-close>取消</button>
</div>
</div>
</div>
<script>
(function () {
var panel = document.getElementById('calSharePanel');
if (!panel) { return; }
var form = panel.querySelector('form');
var idInp = form.querySelector('input[name="id"]');
var ttl = panel.querySelector('.cal-share-title');
function close() { panel.hidden = true; }
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var open = t.closest('[data-cal-share-open]');
if (open) {
e.preventDefault();
idInp.value = open.getAttribute('data-cal-id') || '';
if (ttl) { ttl.textContent = open.getAttribute('data-cal-name') || ''; }
// 重置选人为"未选":单选组件没选人时显示占位(见 _person_picker.php)
if (window.ppSetSelected) { window.ppSetSelected('person_id', 0, panel); }
var perm = panel.querySelector('select[name="perm"]');
if (perm) { perm.value = 'read'; }
panel.hidden = false;
return;
}
if (t.closest('[data-cal-share-close]')) { close(); return; }
// 点遮罩关闭(点面板内不关)
if (t === panel) { close(); }
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !panel.hidden) { close(); }
});
})();
</script>
<?php endif; ?>