Files
FamilyBook/templates/calendar/_event_fields.php
T
laoyang 4ccbcc3360 v45 第 45 批:日程「重复到」换最新控件 + CalDAV 双向同步
用户 2026-10-12 两条:

1. 新建日程里「重复到」的那个日历选择框,改成最新控件。
   - templates/calendar/_event_fields.php:裸 <input type=date> 换成
     .dt-input.dt-antd-single[data-antd-date=1](AntD 纯日期 DatePicker),
     复用既有 mountAntdDate,不新写挂载逻辑。
   - 值载体仍是 name=rep_until 的原生框(包在 .dt-native 里),
     服务端契约与 Repeat::build($_POST) 一字不改。

2. CalDAV 从「只读」改成「双向同步」。
   用户给了一台可用的参照服务器(群晖 DAViCal)要求「完全按参照返回」;
   抓参照 + 复刻 Thunderbird / SyncCalendar 的真实请求序列后确认:
   参照是可写的,而只读会让两个客户端都判「不可用」。
   - dav.php:放行 PUT(新建 201+ETag / 更新 204+ETag / If-Match 不符 412)、
     DELETE(204)、MKCALENDAR(201+Location),仍逐本判权;
     OPTIONS 的 Allow 与 DAV 头照参照补齐(DAV 头两行,注意 PHP header 追加);
     current-user-privilege-set 按实际可写性给,并排除系统派生本(农历等)。
   - app/CalDav.php:资源名统一用 UID(原自定义本用 id、派生本用 uid 两种混着,
     一开写就表现为「客户端 PUT 到 UID 名、服务端 href 是 id 名 ⇒ 写进去读不回」);
     补 owner / getctag / getcontenttype / calendar-description;新增 ctagSeedOf。
   - app/Ics.php:修一个一直存在的真 bug —— 导出 RRULE 漏了 `RRULE:` 前缀
     (库里存的是去前缀的值),导致所有导出/同步出去的重复日程都变成单次。
   - templates/home.php:首页红线补 data-now-min(给探针做零容差判据用)。

验证:本地 e2e 1510/0、TB 探针 35/0、DAV 诊断 28/0、
v43 真机 33/0 + 反向 9/0、v44 真机 41/0 + API 63/0 + 反向 19/0、
v45 重复到 10/0 + 反向 2/0、v45 DAV 双向 24/0 + 反向 5/0;
全流水线 PUSH_RC=0(含部署静态断言第 45 批形态)。
2026-10-12 21:40:53 +08:00

342 lines
20 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
/**
* 日程表单的字段块(新建与编辑共用一份 —— 复制一遍迟早改漏一处)。
*
* 字段按 **RFC 5545(iCalendar)/ RFC 4791(CalDAV)** 的 VEVENT 属性定:
* 标题 = SUMMARY
* 地点 = LOCATION
* 开始/结束 = DTSTART / DTEND(DATETIME,精确到秒)
* 全天 —— VEVENT 没有"全天"这个属性,约定俗成是把 DTSTART 写成 DATE 值;
* 我们统一存 DATETIME,全天时时间部分为 00:00:00,导出 ics 时再落成 DATE 值。
* 提醒 = VALARM 的 TRIGGER(提前多久);不提醒 = 不写 VALARM
* 状态 = STATUS(CONFIRMED / TENTATIVE / CANCELLED)
* 忙闲 = TRANSP(OPAQUE=忙 / TRANSPARENT=空闲)
* 备注 = DESCRIPTION
* 附件 = ATTACH
*
* 用法(include 前设好):
* $evFields 正在编辑的日程(新建时传空数组)
* $evAllDay 全天的默认值(新建默认**不勾**,用户 2026-10-02 第 6 条)
* $evMvStyle 🔴 v43:「标签 62px 右对齐 + 冒号 + 控件」的**观影式**排版开关。
* 新建日程那个弹框要它(用户:日程新建页排版和观影/行程一样);
* 编辑页(`calendar/event.php`)继续用原来的 `.form-grid` 两列。
*/
$evF = is_array($evFields ?? null) ? $evFields : [];
$evAllDayDefault = (bool)($evAllDay ?? false);
$evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evAllDayDefault;
/* 🔴 v43(用户 2026-10-11 第 5 条):观影式排版开关。 */
$evMv = !empty($evMvStyle);
/* 标签包装:观影式用 `.mv-row` + `.mv-lb`(冒号由 CSS ::after 给);
否则用原来的 `.field` + `<label>`。
⚠️ 抽成闭包是为了**不在下面每处 if/else 手抄两遍结构** ——
抄两遍必然有一天两边不同步(v37 那种"改了 A 忘了 B"的坑)。
⚠️ 「必填星号」作为**参数**传(不要用 `use` 捕获 —— 闭包定义时那个变量还没赋值,
捕获到的是 null,星号永远不出现)。 */
$evRowOpen = static function (string $full = '', bool $hidden = false) use ($evMv): string {
return '<div class="' . ($evMv ? 'mv-row' : 'field') . ($full !== '' ? ' ' . $full : '') . '"'
. ($hidden ? ' hidden' : '') . '>';
};
/* 重复规则**先算好**:下面"结束条件 / 重复次数 / 重复到"三行要不要默认藏起来,
取决于它(见 v44 第 2 条)。原来 `$rp` 是在「重复」那一行才 parse 的,
那三行排在它后面 ⇒ 拿不到。提到最前面,只算一次。 */
$evRp = \App\Repeat::parse((string)($evF['rrule'] ?? ''));
$evRepeating = (string)($evRp['freq'] ?? '') !== '';
$evLab = static function (string $text, string $for = '', bool $star = false) use ($evMv): string {
if ($evMv) {
// 观影式:标签是 <span class="mv-lb">,冒号由 CSS ::after 给
return '<span class="mv-lb">' . $text . ($star ? '<i>*</i>' : '') . '</span>';
}
return '<label' . ($for !== '' ? ' for="' . e($for) . '"' : '') . '>' . $text . ($star ? ' *' : '') . '</label>';
};
?>
<?php echo $evRowOpen('full'); ?>
<?= $evLab('标题', 'ev-title', true) ?>
<input class="input" id="ev-title" name="title" required
value="<?= e((string)($evF['title'] ?? '')) ?>" placeholder="如:家长会">
</div>
<?php echo $evRowOpen(); ?>
<?= $evLab('地点', 'ev-location') ?>
<input class="input" id="ev-location" name="location"
value="<?= e((string)($evF['location'] ?? '')) ?>" placeholder="如:学校三楼会议室">
</div>
<?php echo $evRowOpen(); ?>
<?= $evLab('提醒') ?>
<?php /* v24 第 20 批 第 8 条:**多选**(用户原话:「一个日程,我可以选择让它
5分钟提醒,10分钟提醒等等。」)。
⚠️ 用 checkbox 而不是 `<select multiple>`:后者在手机上要"按住再点",
几乎没法用(任务共享那个 multiple 下拉就是因此被换掉的)。
⚠️ 字段名必须是 `reminder_minutes[]` —— Calendar::reminderMinutes() 认得数组。
写成 `reminder_minutes`(不带 [])会让 PHP 只留最后一个勾选。
🔴 v43(用户第 5 条):他要求把这一栏底下那句「多选与不提醒」的说明去掉
⇒ 这里删掉那个 `.tip`(多选语义由复选框本身表达)。 */
$curReminders = \App\Calendar::reminderList($evF['reminder_minutes'] ?? null); ?>
<div class="rem-picks">
<?php foreach (\App\Calendar::REMINDERS as $mins => $word): ?>
<label class="chk-inline rem-pick">
<input type="checkbox" name="reminder_minutes[]" value="<?= (int)$mins ?>"
<?= in_array((int)$mins, $curReminders, true) ? 'checked' : '' ?>>
<span><?= e($word) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<?php /* v24 第 5、6 条:起止各一个「日期 + 时分秒」合在一起的控件(全局统一),
不再拆成"日期一格 + 时刻一格"。两个控件长得一样,扫一眼就知道哪个是开始。 */ ?>
<?php /* 用户 2026-10-04 第 7 条:日程的起止也是一个**范围** ——
升级后由一个 Semi dateTimeRange 一次选完。服务端仍收两组 *_date/_hour/… 字段。 */ ?>
<?php /* 🔴 v24 第 21 批 第 7 条:「开始时间和结束时间那里,用我们统一的时间范围选择器
重做,现在功能无法使用。」
「功能无法使用」的真根因(真机探针实测出来的,不是猜的):
app.js 的 upgradeRange() 有一道「**两端都得可见**才升级成范围控件」的判断
(`boxes.every(bx => bx.offsetParent !== null)`)。而这一版**结束那一端默认
带 `hidden`**(要点「+ 添加结束时间」才出现)→ 判断永远不成立 →
范围控件**从来没被挂上去过**(实测 data-semi-range-done 恒为 null)。
更糟的是 CSS 还有 `html.js .dt-range:not(.is-semi-range) > .dt-input
{ visibility: hidden }` —— 两端于是都是**不可见**的:
屏幕上一片空白、点哪儿都没反应。这就是「无法使用」的全部。
修法:**结束那一端不再默认藏起来**,范围控件一次性就挂上。
起止成对本来就该一起填。想表达"只有一个时刻"的话,范围控件 clear 一下即可。
⚠️ 顺带删掉「+ 添加结束时间 / 去掉结束时间」那套开关:
它切换显隐会让范围控件反复重挂;范围控件自己就能表达"没有结束"。
🔴🔴 v43(用户 2026-10-11 第 5 条):「开始和结束时间也没有用我们最新的控件,
直接就是四个字"时间范围"。」
—— 两处真缺陷一起修:
① 这个字段块**没传 `$dtPlugin='antd'`** ⇒ 用户看到的还是年/月/日 + 时/分
五个原生下拉,而不是他点名要的 Ant Design 控件;
② 标签写的是「时间范围」四个字,没告诉人"这是开始 / 结束"。
⇒ `$dtPlugin='antd'`(与观影/行程同一套:面板有可点的时/分列、中文「确定」),
标签改「开始 / 结束」(并去掉那颗必填星号的位置歧义 —— 只标在开始上)。
⚠️ 契约一字未改:隐藏字段仍是 `*_date/_hour/_minute`;antd 拉不动时
自动退回自建下拉(渐进增强)。
🔴🔴 v44(用户 2026-10-12 第 2 条):「开始/结束,改为:日期范围:。
之前的日期选择控件还有,需要删除。」
—— 用户要三种东西:① 标签「日期范围」;② 标签与**最新控件同行**;
③ 原生那套(年/月/日 + 时/分 五个下拉)**真的消失**。
🔴🔴 **"还在"的真根因(结构性,不是样式没写)**:
观影那对是 `‹div.mv-row.dt-range› ‹div.dt-input›`(**直接子**),
而日程这对是 `‹div.dt-range› ‹div.field.ev-dt› ‹div.dt-input›`
—— `.dt-input` 是**孙子**。
于是那条最常见的收起规则 `.dt-range.is-antd > .dt-input`(**直接子**选择器)
**在日程上根本不命中** ⇒ antd 挂上了、原生五个下拉照样显示
(用户看到两个控件并排,"之前的日期选择控件还有")。
⚠️ 与 v43 纪念日那次是**同一个家族**:结构一变,直接子选择器就静默失效。
⇒ 两手都做:① 把日程的结构改成与观影**一致**(`.dt-input` 直接子);
② CSS 里把 `>` 放宽成后代选择器(0,2,0 → 仍高于基类),
将来结构再变也不会又冒出第二个控件。
⇒ 形态:由 `_datetime.php` 的 `$dtLabel` 把「日期范围:」
**内联在控件那一行**(不靠宿主的行结构),一个 `.mv-lb` 挂在
`.dt-input` 里 → 三样天然同行,宽度不够时整体换行。 */ ?>
<div class="dt-range<?= $evMv ? ' mv-row is-tagged' : '' ?>">
<?php /* ⚠️ 标签只有**一个**,挂在**第一个端点**上(不是各挂各的):
升级成一个范围控件之后,"开始"与"结束"就是同一个控件的两个端点,
两个标签会变成"看着像两个字段、其实是一个"的错觉。
⚠️ 观影式:标签由 `_datetime.php` 内联(`$dtLabel`)—— 要的是与控件同行;
两列式(编辑整页):标签仍是 `.dt-range-label`,在两端之上。 */ ?>
<?php if (!$evMv): ?>
<span class="dt-range-label">日期范围 *</span>
<?php endif; ?>
<?php $dtName = 'starts_at'; $dtId = 'ev-start';
$dtValue = $evF['starts_at'] ?? null; $dtRequired = true;
$dtRange = 'start'; $dtPlugin = 'antd'; $dtClass = 'field ev-dt'; $dtFieldId = '';
if ($evMv) { $dtLabel = '日期范围'; $dtLabelStar = true; }
require __DIR__ . '/../_datetime.php'; ?>
<?php $dtName = 'ends_at'; $dtId = 'ev-end';
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false;
$dtRange = 'end'; $dtPlugin = 'antd';
$dtClass = 'field ev-dt'; $dtFieldId = 'ev-end-field';
/* 结束端点不重复标签(同一个范围控件,标签已经挂在开始那一端上) */
$dtLabel = ''; $dtLabelStar = false;
require __DIR__ . '/../_datetime.php'; ?>
</div><?php /* /dt-range */ ?>
<?php /* 🔴 v43(用户第 5 条):他要求把「结束留空即无明确结束」的那条提示去掉
⇒ 那句 `.field-hint` 整块删除(不再输出)。 */ ?>
<?php echo $evRowOpen(); ?>
<label class="chk-inline">
<input type="checkbox" id="ev-allday" name="all_day" value="1"
<?= $evIsAllDay ? 'checked' : '' ?>>
<span>全天</span>
</label>
<?php /* 🔴 v43(用户第 5 条):他要求把「全天只管日期、不管几点」的说明也去掉
⇒ 那句 `.field-hint` 整块删除。 */ ?>
</div>
<?php /* 用户 2026-10-03 第 5、6 条:重复日程。
放在「全天」之后的自然位置:先定"哪一天/几点",再定"要不要重复"。
周期用 FREQ 记号,结束条件三档(一直 / N 次 / 到某天),另加"工作日"。 */ ?>
<?php echo $evRowOpen(); ?>
<?= $evLab('重复', 'ev-freq') ?>
<select class="select" id="ev-freq" name="freq">
<?php foreach (\App\Repeat::FREQS as $k => $w): ?>
<option value="<?= e($k) ?>"<?= (string)$evRp['freq'] === (string)$k ? ' selected' : '' ?>><?= e($w) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php /* 🔴 v44(用户 2026-10-12 第 2 条):「重复这里,只有选择重复的时候,
下面的结束条件和重复次数、重复到,这几个选项才出现,否则就隐藏。」
⇒ 三行**服务端**就按"是否重复"给出 `hidden`(不是只靠 JS):
· 回填编辑(本来就有 rrule)时它们该出现;
· 新建(默认"不重复")时它们从一开始就是隐藏的 —— 页面上不会闪一下才收起来。
⚠️ 行**本身**也藏:原来只有行**里面**那个 box 带 hidden,
于是标签还孤零零留在表单里(用户看到的"还在")。
⚠️ 用户同条还要「重复次数(含第一次)」那个括号去掉 ⇒ 标签改「重复次数」。 */ ?>
<?php echo $evRowOpen('', !$evRepeating); ?>
<?= $evLab('结束条件', 'ev-rep-end') ?>
<div id="ev-rep-end-box"<?= $evRepeating ? '' : ' hidden' ?>>
<select class="select" id="ev-rep-end" name="rep_end">
<?php foreach (\App\Repeat::ENDS as $k => $w): ?>
<option value="<?= e($k) ?>"><?= e($w) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<?php echo $evRowOpen('', !$evRepeating || (int)$evRp['count'] <= 0); ?>
<?= $evLab('重复次数', 'ev-rep-count') ?>
<div id="ev-rep-count-box"<?= ($evRepeating && (int)$evRp['count'] > 0) ? '' : ' hidden' ?>>
<input class="input" type="number" min="1" max="2000" id="ev-rep-count" name="rep_count"
value="<?= (int)($evRp['count'] ?: 10) ?>">
</div>
</div>
<?php echo $evRowOpen('', !$evRepeating || (string)$evRp['until'] === ''); ?>
<?= $evLab('重复到', 'ev-rep-until') ?>
<div id="ev-rep-until-box"<?= ($evRepeating && (string)$evRp['until'] !== '') ? '' : ' hidden' ?>>
<?php /* 🔴 v45(用户 2026-10-12 第 1 条):「添加日程这里,当选择了重复,
并且选择了重复到某天为止,这时候重复到会出现,有一个日历选择框,
这个日历选择框也改为我们最新的控件。」
⇒ 把原来那个原生 `<input type="date">` 换成 AntD **纯日期** DatePicker。
⚠️ 做法与「纪念日 → 日期」完全同一套(v42/v43 已验证):
· 外层 `.dt-input` + `data-antd-date="1"` → app.js 的 `mountAntdDate` 接管;
· 值载体仍是**同一个** `name="rep_until"` 的 `<input type="date">`,
只是被 `.dt-native` 包起来(否则那条"收起原生框"的 CSS 不命中 ⇒
会出现"多了一个控件",正是 v43 纪念日踩过的坑);
· 服务端契约**一字不改**:`Repeat::build($_POST)` 照旧读 `rep_until`。
⚠️ 千万别给这个 `.dt-input` 加 `data-dt="…"`:那是"年/月/日+时/分"那套
四字段契约的标记,加上去会被 `upgrade()` 抢走、反而绑错处理器。 */ ?>
<div class="dt-input dt-antd-single" data-antd-date="1" id="ev-rep-until-wrap">
<span class="dt-native">
<input type="date" class="dt-date" id="ev-rep-until" name="rep_until"
value="<?= e((string)($evRp['until'] ?? '')) ?>" aria-label="重复到">
</span>
</div>
</div>
</div>
<?php echo $evRowOpen(); ?>
<?= $evLab('状态', 'ev-status') ?>
<select class="select" id="ev-status" name="status">
<?php $st = (string)($evF['status'] ?? 'confirmed'); ?>
<option value="confirmed"<?= $st === 'confirmed' ? ' selected' : '' ?>>确定</option>
<option value="tentative"<?= $st === 'tentative' ? ' selected' : '' ?>>待定</option>
<option value="cancelled"<?= $st === 'cancelled' ? ' selected' : '' ?>>取消</option>
</select>
</div>
<?php /* 🔴 v44(用户 2026-10-12 第 2 条):「这段时间算忙碌,这一块直接给它去掉就行了,
没有什么用。」⇒ 「这段时间算 / 忙碌 / 空闲」整块删除。
⚠️ 服务端契约**没变**:`transp` 字段没随表单提交时,`!empty($_POST['transp'])`
一律得 false(= 忙碌),与原来的默认值一致 —— 不会因为删了这一块
而把已有日程的忙闲改掉(更新时传的是 `(int)($ev2['transp'] ?? 0)` 原值)。
下方 script 里原来还有一段给 `ev-transp` 的联动,也一并删。 */ ?>
<?php echo $evRowOpen('full'); ?>
<?= $evLab('备注', 'ev-note') ?>
<textarea class="input" id="ev-note" name="note" rows="3"
placeholder="要带什么、跟谁一起…"><?= e((string)($evF['note'] ?? '')) ?></textarea>
</div>
<?php echo $evRowOpen('full'); ?>
<?= $evLab('附件(可多选)') ?>
<?php /* v24 第 21 批 第 7 条:统一成「点击或拖动进行上传」的框。
⚠️ 文件框的 id 保持 `ev-attach`(原来就叫这个)—— e2e 与部署断言里
有按 id 定位的写法,改名会把它们静默打成假红。 */
$sdName = 'attach[]'; $sdId = 'ev-attach'; $sdSlot = 'photo';
require __DIR__ . '/../_stage_drop.php'; ?>
</div>
<script>
(function () {
/* 「全天」勾上就把两个时刻控件的**时间部分**收起来 —— 日期还得留着。
只藏 .dt-time(时/分/秒那一段),.dt-date 是日期,藏了就没法选哪天了。 */
var box = document.getElementById('ev-allday');
if (!box) return;
function sync() {
Array.prototype.forEach.call(document.querySelectorAll('[data-dt] .dt-time'), function (el) {
el.hidden = box.checked;
});
}
box.addEventListener('change', sync);
sync();
/* ⚠️ v24 第 21 批 第 7 条:原来这里有一整段「结束时间显隐 + 跟着开始时间走」的 JS。
现在起止两端**始终可见**,并且由范围控件统一接管(一次选完两端),
所以那段"同步两端"的逻辑整个删掉 —— 留着不但没用,还会和范围控件抢着改那两个
原生字段(两处都写 → 值互相覆盖)。范围控件选完会自己把两端分别写回原生字段
(见 app.js 的 onRange → writeDT),服务端契约不变。 */
/* 重复:选了周期才显示"结束条件";结束条件选"次数"才显示次数框、选"到某天"才显示日期框。
三行默认都 hidden —— 不重复时一个都不该出现(少即是清楚)。
🔴 v44(用户第 2 条):藏的是**整行**(`.mv-row` / `.field`),不是行里那个 box ——
只藏 box 的话标签还孤零零留在表单里。行 = box 的 `closest('.mv-row, .field')`。 */
var freq = document.getElementById('ev-freq');
var endBox = document.getElementById('ev-rep-end-box');
var endSel = document.getElementById('ev-rep-end');
var cntBox = document.getElementById('ev-rep-count-box');
var untilBox = document.getElementById('ev-rep-until-box');
function rowOf(el) {
return el && el.closest ? el.closest('.mv-row, .field') : null;
}
function toggle(el, on) {
if (!el) { return; }
el.hidden = !on;
var row = rowOf(el);
if (row) { row.hidden = !on; } // 连带整行一起显隐
}
function syncRep() {
if (!freq || !endBox) { return; }
var repeating = freq.value !== '';
toggle(endBox, repeating);
var how = endSel ? endSel.value : 'never';
toggle(cntBox, repeating && how === 'count');
toggle(untilBox, repeating && how === 'until');
}
if (freq) { freq.addEventListener('change', syncRep); }
if (endSel) { endSel.addEventListener('change', syncRep); }
// 回填:编辑一条已有的重复日程时,把结束条件按库里的值选对
<?php if ((int)$evRp['count'] > 0): ?>
if (endSel) { endSel.value = 'count'; }
<?php elseif ((string)$evRp['until'] !== ''): ?>
if (endSel) { endSel.value = 'until'; }
<?php endif; ?>
syncRep();
/* 「全天」+「重复」的组合提示:重复日程必须有开始日期,
否则展开时找不到锚点(后端会当成"不重复"处理)。 */
var startDate = document.querySelector('[data-dt] input.dt-date');
if (freq && startDate) {
freq.addEventListener('change', function () {
if (freq.value !== '' && startDate.value === '') {
startDate.setCustomValidity('重复日程要先选开始日期');
} else {
startDate.setCustomValidity('');
}
});
}
})();
</script>
<?php unset($evF, $evAllDayDefault, $evIsAllDay, $evFields, $evAllDay, $evMv, $evMvStyle,
$evRowOpen, $evLab, $evLabelStar, $evRp, $evRepeating); ?>