Files
FamilyBook/templates/calendar/_event_fields.php
T
laoyang c3d00ad66a v24 第 20 批:拖动即时上传 / 观影图片带出 / 行程保存修复 / 日程红线与删除范围与多提醒 / 悬停统一(用户 9 条)
用户 9 条原话要点:
  ①「观影模块,编辑观影,已经保存的海报和票根、合影,都没带进来。」
  ②「个人设置页面,"只影响你自己看到的样子"这个提示删除,不要了。我怎么称呼家人,
     这个卡片,不要了。我的资料这个卡片,不要了。」
  ③「旅程模块,编辑行程,点击保存没有反应。」
  ④「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,拖动一个进去,
     就开始上传,还可以继续拖动,继续上传,最后统一保存即可。」
  ⑤「添加行程,线路名、承运方,都不要了。车辆,只在出行方式选择自驾时才出现,
     其他情况下默认不出现。出发地和到达地,应该在同一行。」
  ⑥「日程模块,周视图和日视图,应该在视图中间显示一条红线,用来表示当前进行的时间…
     用一个不断从上到下的红线,来表示当前时间在视图上的刻度和位置。」
  ⑦「对于重复的日程,当点击删除的时候,应该提示是删除这一次,还是全部都删除,
     还是删除这一次及以后的。」
  ⑧「日程的提醒,是可以多选的,就是说,一个日程,我可以选择让它 5 分钟提醒,
     10 分钟提醒等等。」
  ⑨「很多按钮的悬停效果非常奇怪,感觉纯粹是换了一个背景色…应该调整所有按钮的悬停效果,
     保持统一,但是不要单纯的换一个不协调的背景色。」

🔴 三个真根因(都是"看着不像 bug"的地方):
  1. **③ 点保存没反应 = `<form>` 嵌套**(HTML 非法):`trips/node_form.php` 的照片缩略图上
     套了 `<form action=photo_del>`,而整块在**外层行程表单内部**。浏览器解析到内层
     `</form>` 时**把外层表单提前关掉** → 「保存」按钮**掉出表单**(真机实测
     `button.form === null`)。源码里完全看不出,**只有真浏览器量 `btn.form` 才现形**;
     且**只有这段行程已经有照片时才触发** —— 所以表现为"有时候好、有时候坏"。
     → 按钮留在原位(布局不动),用 HTML5 `form="id"` 关联到**表单外**的独立表单。
  2. **⑤ 车辆藏不住(第 17 批就写过、一直没生效)**:`.field` 自己声明 `display:flex`,
     作者样式压过浏览器默认的 `[hidden]{display:none}` → `hidden` 形同虚设。
     **本项目第 4 次栽同一个坑**(.att-item / .pv / .pp-item 各一次)→ 补 `.field[hidden]`。
  3. **④ 拖动即时上传的卡片"看不见"**:模板给 `.stage-list` 加了 `hidden`(没人上传时
     不留空边框),JS 建卡片时忘了 `list.hidden = false` → 卡片在 DOM 里但整条清单
     `display:none`、尺寸 0×0。**HTTP 层断言全绿**(HTML 里确实有卡片),
     **只有真机量尺寸才发现** —— 由新写的真机探针抓到。

本批新增能力:
- **拖动即时上传**(第 4 条):新增 `upload_stage.php` + `app/StagedUpload.php` +
  `templates/_stage_drop.php`。拖进来 → **立刻** POST 到暂存目录 → 回 `stoken` →
  放进隐藏字段 `stage[]` → 保存时在 `attach_save_files()` 里**认领**(移文件 + 建附件行)。
  `media.php` 加 `?stage=` 预览(拖完立刻看得见缩略图)。
  ⚠️ 三个必须踩准:**串行队列**(并发会让缩略图顺序乱跳)、**提交守卫**
  (还有没传完就 preventDefault,否则"拖了但没保存上"必然发生)、**卡片清单要 un-hide**。
  ⚠️ 海报/票根走**独立字段名** `stage_poster`/`stage_ticket`(各自单槽位、要按比例重裁)。
  ⚠️ 落点**就近取**(`input.closest('.field')`):一个表单里多个上传区不能串到第一个去。
- **观影编辑带出已存图片**(第 1 条):编辑页显示海报 / 票根 / 合影 + 各自的删除入口;
  新增 `movies/media_del`(只删不换也能删,槽位走白名单);`movies/update` 收编辑页
  新选的照片时**合并**而不是替换(否则一编辑就把已有合影冲掉)。
- **行程表单**(第 5 条):去掉线路名 / 承运方(**库里的值保留**:UPDATE 时不参与,
  与 seat_info/gate 同一套);出发地/到达地包进 `.loc-row` 真同一行;车辆按自驾显隐。
- **当前时间红线**(第 6 条):位置在服务端算(与 `.tg-block` 同一套"24h = 100%"坐标),
  前端每分钟自己挪(对齐到下一分钟整再起定时器),跨午夜 reload;只画在**今天那一列**。
- **重复日程删除三档**(第 7 条):与「编辑」那三档同名同序(全部 / 这一次及以后 /
  只这一次)。「只删这一次」→ `skipOnce`(**绝不删整条系列**);「这一次及以后」→
  老系列截 UNTIL,**必须清掉 COUNT**(两者并存时 COUNT 优先,不清就截不断)。
- **提醒多选**(第 8 条):同列存逗号串(`5,15,60`);Schema 把它从 **INT 改 VARCHAR**
  (INT 存逗号串会被隐式转换,多档会静默只剩第一档);ICS 导出吐**多个 VALARM**。
- **悬停统一**(第 9 条):原来 14 种互不相干的 hover 底色 → 统一成**两套手法**:
  有底色 `filter: brightness(.92)` 同色系加深 + 轻抬升;描边白底 `--hov-veil` 中性薄雾
  + 边框微显。放在 CSS **最末**用一条兜底收口(新按钮自动继承),并把十几条旧规则
  清成"交给统一段"。`person_picker.js` 同时改为按需加载。

验证(本地全套):
- e2e **1004/0**(+21 条本批断言)· 升级 149/0 · 桌面几何 428/0 ·
  **真机探针 25/0**(连续拖两次 / 红线位置与颜色与几何 / 车辆显隐 / 出发到达同行)。
- **反向验证 5/5**:①删除按钮改回嵌套 form ②卡片清单不 un-hide ③观影编辑不渲染海报
  ④车辆隐藏失效 ⑤「只删这一次」做成删全部 —— 五种坏法全部被抓住,还原后复绿 1004/0。
  过程中还**抓到两条弱断言**(只查 DOM 有无、不查可见性/是否真渲染),已一并加强。
- 部署脚本静态断言段本地空跑 **305 条全过**。
2026-10-06 03:14:18 +08:00

282 lines
13 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 条)
*/
$evF = is_array($evFields ?? null) ? $evFields : [];
$evAllDayDefault = (bool)($evAllDay ?? false);
$evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evAllDayDefault;
?>
<div class="field full">
<label for="ev-title">标题 *</label>
<input class="input" id="ev-title" name="title" required
value="<?= e((string)($evF['title'] ?? '')) ?>" placeholder="如:家长会">
</div>
<div class="field">
<label for="ev-location">地点</label>
<input class="input" id="ev-location" name="location"
value="<?= e((string)($evF['location'] ?? '')) ?>" placeholder="如:学校三楼会议室">
</div>
<div class="field">
<label>提醒</label>
<?php /* v24 第 20 批 第 8 条:**多选**(用户原话:「一个日程,我可以选择让它
5分钟提醒,10分钟提醒等等。」)。
⚠️ 用 checkbox 而不是 `<select multiple>`:后者在手机上要"按住再点",
几乎没法用(任务共享那个 multiple 下拉就是因此被换掉的)。
⚠️ 字段名必须是 `reminder_minutes[]` —— Calendar::reminderMinutes() 认得数组。
写成 `reminder_minutes`(不带 [])会让 PHP 只留最后一个勾选。 */
$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>
<span class="tip">可以选多个(到点各响一次);一个都不选就是不提醒。</span>
</div>
<?php /* v24 第 5、6 条:起止各一个「日期 + 时分秒」合在一起的控件(全局统一),
不再拆成"日期一格 + 时刻一格"。两个控件长得一样,扫一眼就知道哪个是开始。 */ ?>
<?php /* 用户 2026-10-04 第 7 条:日程的起止也是一个**范围** ——
升级后由一个 Semi dateTimeRange 一次选完。服务端仍收两组 *_date/_hour/… 字段。 */ ?>
<div class="dt-range">
<div class="field ev-dt" data-dt>
<label>开始 *</label>
<?php $dtName = 'starts_at'; $dtId = 'ev-start';
$dtValue = $evF['starts_at'] ?? null; $dtRequired = true; $dtSeconds = true;
$dtRange = 'start';
require __DIR__ . '/../_datetime.php'; ?>
</div>
<?php /* 用户 2026-10-03 第 6 条:「添加结束日期与时间」——
结束是可选的:没填就表示"就一个时刻/就这一天",填了才画成一段。
所以给一个显式开关,而不是让人对着空控件猜"不填行不行"。 */ ?>
<?php $hasEnd = trim((string)($evF['ends_at'] ?? '')) !== ''; ?>
<div class="field ev-dt" data-dt id="ev-end-field"<?= $hasEnd ? '' : ' hidden' ?>>
<label>结束</label>
<?php $dtName = 'ends_at'; $dtId = 'ev-end';
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false; $dtSeconds = true;
$dtRange = 'end';
require __DIR__ . '/../_datetime.php'; ?>
</div>
</div><?php /* /dt-range */ ?>
<div class="field" id="ev-end-add">
<label>结束时间</label>
<button type="button" class="btn btn-ghost btn-sm" id="ev-end-add-btn">+ 添加结束时间</button>
<span class="field-hint">不填就表示没有明确的结束(只占开始那一刻/那一天)</span>
</div>
<div class="field">
<label>
<input type="checkbox" id="ev-allday" name="all_day" value="1"
<?= $evIsAllDay ? 'checked' : '' ?>>
全天
</label>
<span class="field-hint">勾上就不管具体几点,只占这一天</span>
</div>
<?php /* 用户 2026-10-03 第 5、6 条:重复日程。
放在「全天」之后的自然位置:先定"哪一天/几点",再定"要不要重复"。
周期用 FREQ 记号,结束条件三档(一直 / N 次 / 到某天),另加"工作日"。 */ ?>
<div class="field">
<label for="ev-freq">重复</label>
<select class="select" id="ev-freq" name="freq">
<?php $rp = \App\Repeat::parse((string)($evF['rrule'] ?? '')); ?>
<?php foreach (\App\Repeat::FREQS as $k => $w): ?>
<option value="<?= e($k) ?>"<?= (string)$rp['freq'] === (string)$k ? ' selected' : '' ?>><?= e($w) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field" id="ev-rep-end-box" hidden>
<label for="ev-rep-end">结束条件</label>
<select class="select" id="ev-rep-end" name="rep_end">
<?php foreach (\App\Repeat::ENDS as $k => $w): ?>
<option value="<?= e($k) ?>"<?= $rp['count'] > 0 ? '' : '' ?>><?= e($w) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field" id="ev-rep-count-box" hidden>
<label for="ev-rep-count">重复次数(含第一次)</label>
<input class="input" type="number" min="1" max="2000" id="ev-rep-count" name="rep_count"
value="<?= (int)($rp['count'] ?: 10) ?>">
</div>
<div class="field" id="ev-rep-until-box" hidden>
<label for="ev-rep-until">重复到</label>
<input class="input" type="date" id="ev-rep-until" name="rep_until"
value="<?= e((string)($rp['until'] ?? '')) ?>">
</div>
<div class="field">
<label for="ev-status">状态</label>
<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>
<div class="field">
<label for="ev-transp">这段时间算</label>
<select class="select" id="ev-transp" name="transp">
<?php $tp = (int)($evF['transp'] ?? 0); ?>
<option value="0"<?= $tp === 0 ? ' selected' : '' ?>>忙碌</option>
<option value="1"<?= $tp === 1 ? ' selected' : '' ?>>空闲(别人还能约)</option>
</select>
</div>
<div class="field full">
<label for="ev-note">备注</label>
<textarea class="input" id="ev-note" name="note" rows="3"
placeholder="要带什么、跟谁一起…"><?= e((string)($evF['note'] ?? '')) ?></textarea>
</div>
<div class="field full">
<label for="ev-attach">附件(可多选)</label>
<input class="input" type="file" id="ev-attach" name="attach[]" multiple>
</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();
/* ---- 用户第 6 条:结束时间 ----
① 「+ 添加结束时间」把结束控件放出来;
② 结束时间**默认跟着开始时间走**:改了开始,若结束还没填(或原本等于旧开始),
就把结束同步过去 —— 常见用法是"改个开始,结束忘了改",结果时长莫名其妙。
但只要用户**自己动过结束**,就不再自动同步(别抢用户的输入)。 */
var startBox = document.querySelector('[data-dt] input.dt-date');
var endField = document.getElementById('ev-end-field');
var endAdd = document.getElementById('ev-end-add');
var endAddBtn = document.getElementById('ev-end-add-btn');
var startField = document.querySelector('.field.ev-dt');
function endInputs() {
if (!endField) return [];
return Array.prototype.slice.call(endField.querySelectorAll('input, select'));
}
function showEnd(on) {
if (endField) { endField.hidden = !on; }
if (endAdd) { endAdd.hidden = on; }
// 结束时间刚被显示出来 → 现在两端都可见了,可以合并成一个范围控件(用户第 7 条)。
// 收起时不动已有的范围(它本来就只显示在第一端上,不影响使用)。
if (on && typeof window.fbUpgradeRange === 'function') {
window.fbUpgradeRange();
}
}
var endTouched = false;
endInputs().forEach(function (el) {
el.addEventListener('change', function () { endTouched = true; });
});
if (endAddBtn) {
endAddBtn.addEventListener('click', function () { showEnd(true); endTouched = false; });
}
// 移除结束(改回"没有结束")
if (endField && !endField.querySelector('.ev-end-del')) {
var del = document.createElement('button');
del.type = 'button';
del.className = 'btn btn-ghost btn-sm ev-end-del';
del.textContent = '去掉结束时间';
del.addEventListener('click', function () {
endInputs().forEach(function (el) {
if (el.tagName === 'SELECT') { el.value = '00'; }
else { el.value = ''; }
});
showEnd(false);
endTouched = false;
});
endField.appendChild(del);
}
// 开始变了 → 结束(若没被手动改过)跟着走同一时刻
if (startField) {
startField.addEventListener('change', function () {
if (endTouched || !endField || endField.hidden) return;
var sd = startField.querySelector('input.dt-date');
var sh = startField.querySelector('select.dt-hour');
var sm = startField.querySelector('select.dt-minute');
var ss = startField.querySelector('select.dt-second');
var ed = endField.querySelector('input.dt-date');
var eh = endField.querySelector('select.dt-hour');
var em = endField.querySelector('select.dt-minute');
var es = endField.querySelector('select.dt-second');
if (sd && ed) { ed.value = sd.value; }
if (sh && eh) { eh.value = sh.value; }
if (sm && em) { em.value = sm.value; }
if (ss && es) { es.value = ss.value; }
});
}
// 初值由服务端决定(有 ends_at 就是展开的),JS 只负责"之后"的显隐
showEnd(<?= $hasEnd ? 'true' : 'false' ?>);
/* 重复:选了周期才显示"结束条件";结束条件选"次数"才显示次数框、选"到某天"才显示日期框。
三个框默认都 hidden —— 不重复时一个都不该出现(少即是清楚)。 */
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 syncRep() {
if (!freq || !endBox) return;
var repeating = freq.value !== '';
endBox.hidden = !repeating;
var how = endSel ? endSel.value : 'never';
if (cntBox) { cntBox.hidden = !repeating || how !== 'count'; }
if (untilBox) { untilBox.hidden = !repeating || how !== 'until'; }
}
if (freq) { freq.addEventListener('change', syncRep); }
if (endSel) { endSel.addEventListener('change', syncRep); }
// 回填:编辑一条已有的重复日程时,把结束条件按库里的值选对
<?php if ($rp['count'] > 0): ?>
if (endSel) { endSel.value = 'count'; }
<?php elseif ($rp['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); ?>