Files
FamilyBook/templates/calendar/event.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

174 lines
8.8 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
/**
* 一条自定义日程:查看 / 改 / 删。
*
* ⚠️ 从月视图点**观影 / 旅程 / 任务**那些条会直接跳到对应模块(它们的"编辑页"在那里),
* 这里只处理 calendar_events 里真正属于某本日历的自定义日程。
*/
$canWrite = \App\Auth::canWrite() && !empty($ev['_can_write']);
?>
<div class="page-head">
<h1><?= e((string)$ev['title']) ?></h1>
<span class="sub"><?= e((string)($ev['starts_at'] ?? '')) ?></span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => substr((string)$ev['starts_at'], 0, 7)])) ?>">返回日程</a>
</div>
</div>
<div class="card card-pad">
<?php $ym = substr((string)$ev['starts_at'], 0, 7); ?>
<?php if (!$canWrite): ?>
<dl class="kv">
<div class="kv-row"><dt>开始</dt>
<dd><?= e(empty($ev['all_day']) ? (string)$ev['starts_at'] : substr((string)$ev['starts_at'], 0, 10)) ?></dd>
</div>
<?php if (!empty($ev['ends_at'])): ?>
<div class="kv-row"><dt>结束</dt>
<dd><?= e(empty($ev['all_day']) ? (string)$ev['ends_at'] : substr((string)$ev['ends_at'], 0, 10)) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($ev['all_day'])): ?>
<div class="kv-row"><dt></dt><dd>全天</dd></div>
<?php endif; ?>
<?php if (!empty($ev['location'])): ?>
<div class="kv-row"><dt>地点</dt><dd><?= e((string)$ev['location']) ?></dd></div>
<?php endif; ?>
<?php /* v24 第 20 批 第 8 条:提醒可以**多档**,所以要列全("提前 5 分钟、提前 1 小时")。
取的是同一个逗号串,Calendar::reminderWords() 负责翻成人话。 */ ?>
<?php $rvWords = \App\Calendar::reminderWords($ev['reminder_minutes'] ?? null); ?>
<?php if ($rvWords !== ''): ?>
<div class="kv-row"><dt>提醒</dt><dd><?= e($rvWords) ?></dd></div>
<?php endif; ?>
<?php if (!empty($ev['note'])): ?>
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e((string)$ev['note'])) ?></dd></div>
<?php endif; ?>
<?php $roAs = \App\Attachment::list(\App\Calendar::ATTACH, (int)$ev['id']); ?>
<?php if ($roAs): ?>
<div class="kv-row"><dt>附件</dt>
<dd class="attach-row">
<?php foreach ($roAs as $a): ?>
<span class="attach-item">
<a href="<?= e(media_url((string)$a['key'])) ?>" target="_blank" rel="noopener">
<?= (int)$a['is_image'] === 1 ? '🖼' : '📄' ?> <?= e((string)($a['name'] ?: '附件')) ?>
</a>
<a class="link-quiet" href="<?= e(media_url((string)$a['key'], ['dl' => 1])) ?>">下载</a>
</span>
<?php endforeach; ?>
</dd>
</div>
<?php endif; ?>
</dl>
<p class="empty-hint">这本日历你只有只读权限,看得到但不能改。</p>
<?php else: ?>
<form method="post" action="<?= e(url('calendar/event_update')) ?>" enctype="multipart/form-data">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
<input type="hidden" name="ym" value="<?= e($ym) ?>">
<?php /* 这一次是哪一天:从日历点进来时 URL 带 ?d=YYYY-MM-DD。
"仅此一次 / 这一次及以后"都要靠它定位。 */ ?>
<input type="hidden" name="occ" value="<?= e($occ ?? '') ?>">
<?php /* 用户 2026-10-03 第 5 条:改一条**重复**日程时,先选"改哪些"。
只在它真的重复时才出现 —— 不重复的日程问这个是多余的。 */ ?>
<?php if (trim((string)($ev['rrule'] ?? '')) !== ''): ?>
<div class="card card-pad ev-scope">
<b>这是一条重复日程</b>
<p class="small muted" style="margin:6px 0 10px">
<?= e(\App\Repeat::describe((string)$ev['rrule'])) ?>
<?php if (!empty($occ)): ?>
<br>你打开的是 <b><?= e($occ) ?></b> 那一次。
<?php endif; ?>
</p>
<label class="chk-inline"><input type="radio" name="scope" value="all" checked>
<span>改<b>全部</b>(整条系列)</span></label>
<?php if (!empty($occ)): ?>
<label class="chk-inline"><input type="radio" name="scope" value="future">
<span>改<b>这一次及以后</b>(<?= e($occ) ?> 起,过去的保持不变)</span></label>
<label class="chk-inline"><input type="radio" name="scope" value="once">
<span>只改<b>这一次</b>(<?= e($occ) ?>)</span></label>
<?php else: ?>
<p class="small muted" style="margin:6px 0 0">
(从日历点某一天的日程进来,才能选「只改这一次 / 这一次及以后」。)
</p>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="form-grid">
<?php $evFields = $ev; $evAllDay = false; require __DIR__ . '/_event_fields.php'; ?>
</div>
<div class="btn-row">
<button class="btn" type="submit">保存</button>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => $ym])) ?>">取消</a>
</div>
</form>
<?php $as = \App\Attachment::list(\App\Calendar::ATTACH, (int)$ev['id']); ?>
<?php if ($as): ?>
<div class="ev-attach-list">
<span class="res-label">已有附件</span>
<?php
$attRows = $as;
$attCanEdit = true;
$attDelUrl = 'calendar/attach_del';
$attDelField = 'attach_id';
$attExtra = ['id' => (int)$ev['id']];
require __DIR__ . '/../_attachments.php';
?>
</div>
<?php endif; ?>
<?php /* ⚠️ 删除表单必须放在上面那个表单**外面**:<form> 套 <form> 是非法 HTML,
浏览器会把外层表单在切开处把外层提前关掉,"保存"按钮就失效了。 */ ?>
<?php
/* ==================== 删除(v24 第 20 批 用户第 7 条)====================
用户原话:「对于重复的日程,当点击删除的时候,应该提示是删除这一次,
还是全部都删除,还是删除这一次及以后的。」
⚠️ 用**原生 radio + 提交**而不是 JS 弹框:不用 JS 也能选范围,
而且"删哪一档"这件事必须让人看清文字再点(三种后果差别很大,手滑代价高)。
⚠️ 只有**重复日程**才给这三档。不重复的给三档会让人以为"是不是还有别的次数"
(其实就一条),反而制造困惑 —— 那种情况只给一个「删除」。
⚠️ `occ`(点开的是哪一天)必须带上:「只删这一次」「这一次及以后」都靠它定位。
从日历某一天点进来才有它(URL 的 ?d=);从列表直接打开就只给"全部删除"。
⚠️ 三档的措辞与**编辑**那三档保持一致(改全部 / 改这一次及以后 / 只改这一次),
用户学一次就够,不用记第二套说法。
*/
$evIsRepeat = trim((string)($ev['rrule'] ?? '')) !== '';
?>
<?php if ($evIsRepeat && !empty($occ)): ?>
<form method="post" action="<?= e(url('calendar/event_del')) ?>" class="ev-del ev-del-scope"
onsubmit="return confirm('按上面选的范围删除?')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
<input type="hidden" name="ym" value="<?= e($ym) ?>">
<input type="hidden" name="occ" value="<?= e($occ) ?>">
<p class="ev-del-head">这是一条<b>重复日程</b>,删哪些?</p>
<div class="ev-del-picks">
<label class="chk-inline">
<input type="radio" name="scope" value="once">
<span>只删<b>这一次</b>(<?= e($occ) ?>)</span>
</label>
<label class="chk-inline">
<input type="radio" name="scope" value="future">
<span>删<b>这一次及以后</b>(<?= e($occ) ?> 起,之前的留着)</span>
</label>
<label class="chk-inline">
<input type="radio" name="scope" value="all" checked>
<span>删<b>全部</b>(整条系列)</span>
</label>
</div>
<button class="btn btn-danger btn-sm" type="submit">删除</button>
</form>
<?php else: ?>
<form method="post" action="<?= e(url('calendar/event_del')) ?>" class="ev-del"
onsubmit="return confirm('删除这条日程?')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
<input type="hidden" name="ym" value="<?= e($ym) ?>">
<?php if (!empty($occ)): ?><input type="hidden" name="occ" value="<?= e($occ) ?>"><?php endif; ?>
<input type="hidden" name="scope" value="all">
<button class="btn btn-danger btn-sm" type="submit">删除这条日程</button>
</form>
<?php endif; ?>
<?php endif; ?>
</div>