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

542 lines
29 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,v24 第 4、5 条重排)。
*
* ⚠️ v24 用户 2026-10-03 第 4 条:**左侧那一栏「我的日历本」撤掉了**,日历占满整宽。
* 原话:「日程模块,左侧的日历本显示与否,去掉,让日历充满整个宽度。
* 在右侧,也加一个设置按钮,设置中,可以设置哪些日历本显示,
* 调整日历的颜色,导入日历等功能,都放在这个设置下。」
* 所以那些功能全搬进了页头最右边那个 ☰ 里(见 _settings_menu.php)。
* 显隐改由**服务端**处理(Calendar::hiddenCalendars):藏掉的本子不参与取数,
* "+N 条"之类按可见条目算 —— 前端 display:none 会让这些数字对不上。
*
* ⚠️ 用户第 5 条:在日历里点一下就建日程(开始时间 = 点的那一格)。
* 月视图的格子按天、周/日视图按半小时——
* 后者用鼠标在列里的纵向位置反算时刻(见页面底部那两段 JS)。
*
* @param string $ym 'Y-m'
* @param array $weeks 月网格(build_month_grid 产出)
* @param array $cals 我可见的日历本(含 perm);$visCals 是其中显示着的 id
* @param string $newAt 点进来时预填的开始时间('Y-m-d H:i' 或 'Y-m-d')
*/
$canWrite = \App\Auth::canWrite();
$view = (string)($view ?? 'month');
$anchor = (string)($anchor ?? date('Y-m-d'));
$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())));
$visCals = array_map('intval', (array)($visCals ?? []));
$hiddenCals = array_map('intval', (array)($hiddenCals ?? []));
$newAt = (string)($newAt ?? '');
// 能不能"点一下建日程":只读账号点了没反应,所以连十字光标都不给(见 CSS 的 rt-writable)
$canNew = $canWrite && (bool)$writable;
// 三种看法各自的「上一段 / 下一段」:月按整月走,周按 7 天走,天按 1 天走
$step = $view === 'week' ? 7 : ($view === 'day' ? 1 : 0);
$prevD = date('Y-m-d', strtotime('-' . $step . ' days', strtotime($anchor)));
$nextD = date('Y-m-d', strtotime('+' . $step . ' days', strtotime($anchor)));
$todayD = date('Y-m-d');
$headText = $view === 'month' ? date('Y 年 n 月', $ts)
: ($view === 'week' ? date('Y 年 n 月 j 日', strtotime($anchor)) . ' 起这一周'
: date('Y 年 n 月 j 日', strtotime($anchor)));
?>
<div class="page-head">
<h1>日程</h1>
<span class="sub"><?= e($headText) ?></span>
<div class="actions">
<?php /* v24 第 4 条:月 / 周 / 天三选一 */ ?>
<span class="seg" role="group" aria-label="看法">
<a class="seg-btn<?= $view === 'month' ? ' is-on' : '' ?>"
href="<?= e(url('calendar', ['view' => 'month', 'ym' => $ym])) ?>">月</a>
<a class="seg-btn<?= $view === 'week' ? ' is-on' : '' ?>"
href="<?= e(url('calendar', ['view' => 'week', 'd' => $anchor])) ?>">周</a>
<a class="seg-btn<?= $view === 'day' ? ' is-on' : '' ?>"
href="<?= e(url('calendar', ['view' => 'day', 'd' => $anchor])) ?>">天</a>
</span>
<?php if ($view === 'month'): ?>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => 'month', 'ym' => $prevYm])) ?>">上一月</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => 'month', 'ym' => $thisYm])) ?>">今天</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => 'month', 'ym' => $nextYm])) ?>">下一月</a>
<?php else: ?>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => $view, 'd' => $prevD])) ?>">
<?= $view === 'week' ? '上一周' : '前一天' ?>
</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => $view, 'd' => $todayD])) ?>">今天</a>
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => $view, 'd' => $nextD])) ?>">
<?= $view === 'week' ? '下一周' : '后一天' ?>
</a>
<?php endif; ?>
<?php if ($canWrite && $writable): ?>
<button class="btn" type="button" id="calAddBtn">新建日程</button>
<?php endif; ?>
<?php /* 用户 2026-10-04 第 6 条原话:「日程页面,在设置按钮的前面,加一个日历按钮,
选择日历后,跳转到那一天。」
→ 一个带日历图标的按钮,点开是日期选择;选中即跳到那天的**日视图**。
⚠️ 用原生 <input type="date"> 而不是再引一个弹层:它的原生日历足够用,
而且**选中就有值**(change 事件)→ 直接跳转,不用再点一次"确定"。
桌面/手机/无 JS 三种情况下的行为都是可预期的。 */ ?>
<?php /* 用户 2026-10-05 第 12 批 #3 原话(三句):
· 「跳转到某一天时,不要进入日视图,保留当前本来的视图,只不过是跳转到这一天,
如果是周视图,那么就是跳转到这一天所在的周,如果是月视图,则跳转到这一天所在的月」
· 「选择这个日期,不用弹窗,直接把日历选择,弹出在这个按钮下面就可以了」
→ ① 不再固定 view=day:带上**当前视图**(月/周/日各自跳到对应跨度);
② 日期选择从"居中弹窗"改成**贴着按钮下方展开的下拉日历**。
🔴 2026-10-05 第 14 批 #1(用户报严重 bug):「点上一月,页面直接跳转,
我还没选择日期呢」。根因是这一版下拉里挂的是 Semi 的 DatePicker ——
它的日历面板**渲染在 body 的 portal 里**(不在下拉容器内),
而它在面板上翻月时会把"面板当前显示的日期"当成新值回调 onChange,
于是"翻月"被当成了"选中"。第三方控件把"翻页"与"选中"混在一个回调里,
这个语义我们改不了。
→ 改成**自绘内联日历**(几十行、零依赖):翻月/翻年只重绘面板、绝不提交,
只有点**某一天**才跳转,语义 100% 由我们自己定。 */ ?>
<form class="cal-jump" method="get" action="index.php">
<input type="hidden" name="r" value="calendar">
<?php /* 保留当前视图:月→该月 / 周→那一周 / 日→那一天。
⚠️ 原来写死 view=day,用户点"跳到 10 月 20 日"就被踢进日视图,
而他本来在月视图里 —— 这正是不满的点。 */ ?>
<input type="hidden" name="view" value="<?= e($view) ?>">
<input type="hidden" name="d" id="calJumpD" value="<?= e($anchor) ?>">
<?php /* 月视图用的是 ym(年月),跳转时也要带上,否则服务端会退回默认月 */ ?>
<?php if ($view === 'month'): ?>
<input type="hidden" name="ym" id="calJumpYM" value="<?= e(substr($anchor, 0, 7)) ?>">
<?php endif; ?>
<button type="button" class="btn btn-icon cal-jump-btn" id="calJumpBtn"
title="跳到某一天" aria-label="跳到某一天" aria-haspopup="dialog" aria-expanded="false">
<svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
<rect x="2" y="3.2" width="12" height="10.8" rx="1.6"/>
<path d="M2 6.6h12M5.2 1.8v2.6M10.8 1.8v2.6"/>
</svg>
</button>
<?php /* 下拉日历的宿主:紧贴按钮下方展开(不是弹窗)。内容由下面那段 JS 画;
JS 不可用时它是空的、退到下面的 noscript(原生日期框 + 跳转按钮)。 */ ?>
<div class="cal-jump-drop" id="calJumpDrop" hidden></div>
<noscript>
<input class="input" type="date" name="d" value="<?= e($anchor) ?>" aria-label="选择日期">
<button class="btn btn-sm" type="submit">跳转</button>
</noscript>
</form>
<?php /* 用户第 4、5 条:日历本 / 颜色 / 导入 / 默认视图 全收进这个 ☰ ——
它顶替了原来左边那一栏,所以排在**最右**(靠 .page-head .actions 的顺序)。 */ ?>
<?php $setCurrent = 'view'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<div class="cal-wrap cal-wrap-full">
<?php /* ---- 右:月网格 / 时间网格 ----
用户第 4 条:左边那一栏「我的日历本」已经撤掉(功能搬进右上角 ☰),
所以这里直接就是主区域,`.cal-wrap-full` 把网格调成"只有一列"。 */ ?>
<div class="cal-main">
<?php /* 用户第 5 条:这张表单既服务「新建日程」按钮,也服务「点日历空白处」——
后者由 JS 现填开始时间,所以表单本身不再写死 09:00。 */ ?>
<?php if ($canWrite && $writable): ?>
<div class="card card-pad cal-add" id="calAddCard" hidden>
<h2 class="cinema-h2">新建日程</h2>
<form method="post" action="<?= e(url('calendar/event_add')) ?>" enctype="multipart/form-data">
<?= csrf_field() ?>
<input type="hidden" name="ym" value="<?= e($ym) ?>">
<div class="form-grid">
<div class="field">
<label for="ce-cal">放在哪本</label>
<select class="select" id="ce-cal" name="calendar_id">
<?php foreach ($writable as $c): ?>
<option value="<?= (int)$c['id'] ?>"><?= e((string)$c['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php /* v24 第 6 条:字段按 iCalendar / CalDAV 的 VEVENT 定(见 _event_fields.php)。
新建时「全天」默认**不勾**(用户原话)。开始时间默认:
带了 new_at 就用它(点日历的那一格),否则今天 09:00。 */ ?>
<?php
// 点的位置优先;没点就"月视图当月 → 该月 1 号 / 本月 → 今天"的 09:00
$defDay = $newAt !== '' ? substr($newAt, 0, 10)
: (($ym === $thisYm ? date('Y-m-d') : $ym . '-01'));
$defHM = $newAt !== '' && strlen($newAt) > 10
? substr($newAt, 11, 5) : '09:00';
$defEndH = sprintf('%02d', (min(23, (int)substr($defHM, 0, 2) + 1)));
$evFields = ['starts_at' => $defDay . ' ' . $defHM . ':00',
'ends_at' => $defDay . ' ' . $defEndH . ':' . substr($defHM, 3, 2) . ':00'];
$evAllDay = false;
require __DIR__ . '/_event_fields.php';
?>
</div>
<div class="btn-row">
<button class="btn btn-sm" type="submit">加上</button>
<button class="btn btn-ghost btn-sm" type="button" id="calAddCancel">取消</button>
</div>
</form>
</div>
<?php endif; ?>
<?php if ($view === 'week' || $view === 'day'): ?>
<?php /* ===== 周 / 天:按小时铺的时间网格(全天日程单独挂顶上) ===== */ ?>
<div class="card cal-tg-card">
<div class="tg-head">
<div class="tg-gutter"></div>
<?php foreach (($cols ?? []) as $c): ?>
<div class="tg-dayname<?= $c['is_today'] ? ' is-today' : '' ?>">
<?php /* $c['w'] 是 date('w')(0=周日),所以这张索引表保持"日"开头——
它只是把数字换成汉字,与列的排列顺序无关。 */ ?>
<span class="tg-wd"><?= e(['日','一','二','三','四','五','六'][$c['w']]) ?></span>
<span class="tg-dd"><?= (int)$c['day'] ?></span>
</div>
<?php endforeach; ?>
</div>
<?php $tgAll = $tgrid['all_day'] ?? []; ?>
<?php if (array_filter($tgAll)): ?>
<div class="tg-allday">
<div class="tg-gutter"><span class="tg-tag">全天</span></div>
<?php foreach (($cols ?? []) as $i => $c): ?>
<div class="tg-adcell">
<?php foreach (($tgAll[$i] ?? []) as $ev): ?>
<a class="cal-ev is-allday" data-cal="<?= (int)$ev['cal'] ?>"
style="background: <?= e((string)$ev['color']) ?>;"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>"><?= e((string)$ev['title']) ?></a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="tg-body">
<div class="tg-gutter tg-hours">
<?php for ($h = 0; $h < 24; $h++): ?>
<span class="tg-hr"><?= sprintf('%02d', $h) ?></span>
<?php endfor; ?>
</div>
<?php foreach (($cols ?? []) as $i => $c): ?>
<div class="tg-col<?= $c['is_today'] ? ' is-today' : '' ?><?= $canNew ? ' rt-writable' : '' ?>"
data-new-at="<?= e((string)$c['date']) ?>">
<?php for ($h = 0; $h < 24; $h++): ?>
<span class="tg-line"></span>
<?php endfor; ?>
<?php /* ==================== 当前时间红线(v24 第 20 批 用户第 6 条)====================
用户原话:「日程模块,周视图和日视图,应该在视图中间显示一条红线,
用来表示当前进行的时间,就是说,用一个不断从上到下的红线,
来表示当前时间在视图上的刻度和位置。」
⚠️ 只在**今天那一列**画(别的日子没有"现在"可言)。
⚠️ 位置是服务端算好的百分比(与上面 .tg-block 用的是同一套:
列高 = 24h,所以 1 分钟 = 100/1440 %)。**不在 CSS 里写死**
某个时间 —— 那就成了"固定的一条线",不叫"当前时间"。
⚠️ 线的**移动**由 app.js 里的一分钟定时器负责(`initNowLines()`):
服务端给的是"渲染那一刻"的位置,用户开着页面不走,
过一小时线还停在那儿就荒谬了。
⚠️ 全天日程的"全天"那一行**不画** —— 那条线表达的是"一天之内的时间刻度",
画在全天行上会让人以为全天日程跟时间有关。 */ ?>
<?php if ($c['is_today'] && !empty($nowTop)): ?>
<span class="tg-now" data-now-line style="top: <?= (float)$nowTop ?>%;"
title="现在 <?= e(date('H:i')) ?>"></span>
<?php endif; ?>
<?php foreach (($tgrid['timed'][$i] ?? []) as $ev): ?>
<a class="tg-block" data-cal="<?= (int)$ev['cal'] ?>"
style="background: <?= e((string)$ev['color']) ?>; top: <?= (float)$ev['top'] ?>%; height: <?= (float)$ev['height'] ?>%;"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>">
<b><?= e((string)$ev['time']) ?></b> <?= e((string)$ev['title']) ?>
</a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php else: ?>
<div class="card cal-grid-card">
<div class="cal-head">
<?php /* ⚠️ 用户第 11 批 #6:「月视图,从周一开始。而不是周日。」
表头顺序必须与 month_grid_range() 的起算规则一致(那里已改成周一)。 */ ?>
<?php foreach (['一', '二', '三', '四', '五', '六', '日'] as $wd): ?>
<div class="cal-wd"><?= e($wd) ?></div>
<?php endforeach; ?>
</div>
<div class="cal-grid">
<?php foreach ($weeks as $wk): ?>
<?php foreach ($wk as $cell): ?>
<?php /* 用户第 5 条:点格子里的空白 = 在这天建日程。
用 data-new-at 把日期带过去,由底部 JS 交给新建表单
(点了小条本身是"打开那条日程",由 <a> 自己处理,JS 会排除掉)。 */ ?>
<div class="cal-cell<?= $cell['in_month'] ? '' : ' is-out' ?><?= $cell['is_today'] ? ' is-today' : '' ?><?= $canNew ? ' rt-writable' : '' ?>"
data-new-at="<?= e((string)$cell['date']) ?>">
<div class="cal-dnum"><?= (int)$cell['day'] ?></div>
<div class="cal-evs">
<?php $n = 0; foreach ($cell['events'] as $ev): $n++; ?>
<?php if ($n <= 3): ?>
<a class="cal-ev<?= empty($ev['all_day']) ? '' : ' is-allday' ?>"
data-cal="<?= (int)$ev['cal'] ?>"
style="background: <?= e((string)$ev['color']) ?>;"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>">
<?php if (($ev['time'] ?? '') !== ''): ?><b><?= e((string)$ev['time']) ?></b> <?php endif; ?>
<?= e((string)$ev['title']) ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
<?php if (count($cell['events']) > 3): ?>
<span class="cal-more">+<?= count($cell['events']) - 3 ?> 条</span>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<script>
(function () {
/* ---- 新建日程:按钮开合 + 「点日历空白处」预填开始时间 ----
⚠️ 上一版的「日历本显隐」JS 已经**整段删掉**:显隐改成服务端处理
(Calendar::hiddenCalendars,见路由里的说明),前端不再需要它。
留着会让人以为"改一下勾选就能切换",但那些勾选框已经不在这儿了。 */
var btn = document.getElementById('calAddBtn');
var card = document.getElementById('calAddCard');
var cancel = document.getElementById('calAddCancel');
if (btn && card) {
btn.addEventListener('click', function () {
card.hidden = !card.hidden;
if (!card.hidden) { card.scrollIntoView({ block: 'nearest' }); }
});
}
if (cancel && card) { cancel.addEventListener('click', function () { card.hidden = true; }); }
/* ---- 用户第 5 条:在日历里点一下 → 弹新建,开始时间 = 点的位置 ----
月视图:一格 = 一天 → 开始时间用那天 09:00("当日默认"比 00:00 更像个日程)。
周/日视图:按鼠标在**那一列里的纵向位置**反算时刻,最接近的半小时 ——
点在"下午三点那一段"就该得到 15:00,这是"点哪儿建哪儿"的全部意义。 */
var startField = document.querySelector('.field.ev-dt[data-dt]');
var startDate = startField ? startField.querySelector('input.dt-date') : null;
var startHour = startField ? startField.querySelector('select.dt-hour') : null;
var startMin = startField ? startField.querySelector('select.dt-minute') : null;
var startSec = startField ? startField.querySelector('select.dt-second') : null;
function setStart(dateStr, hh, mm) {
// ⚠️ 优先走全局接口:它会把原生字段与 Semi 控件**一起**更新。
// 直接写 startDate.value 的话,Semi 是非受控组件、显示不会跟着变 ——
// 用户会看到"我点了 15:00,控件里还写着 09:00"(提交的却是 15:00)。
if (typeof window.fbSetDT === 'function'
&& window.fbSetDT('starts_at', dateStr + ' ' + (hh || '09') + ':' + (mm || '00') + ':00')) {
return;
}
// 兜底:Semi 还没加载完(或加载失败)→ 原生控件照旧能用
if (!startDate) return;
startDate.value = dateStr;
if (hh !== null && startHour) { startHour.value = hh; }
if (mm !== null && startMin) { startMin.value = mm; }
if (startSec) { startSec.value = '00'; }
}
function openNew(dateStr, hh, mm) {
if (!card) return;
card.hidden = false;
setStart(dateStr, hh, mm);
card.scrollIntoView({ block: 'nearest' });
// 标题是第一个输入框,直接把光标放过去 —— 点完就能打字
var t = document.getElementById('ev-title');
if (t) { t.focus(); }
}
/* 月视图格子 */
Array.prototype.forEach.call(document.querySelectorAll('.cal-cell[data-new-at]'), function (cell) {
cell.addEventListener('click', function (ev) {
// 点在小条上 = 想打开那条日程(<a> 的默认行为),不要抢
if (ev.target.closest && ev.target.closest('.cal-ev')) { return; }
if (!<?= $canWrite && $writable ? 'true' : 'false' ?>) { return; }
openNew(cell.getAttribute('data-new-at'), '09', '00');
});
});
/* 周/日视图的列:纵向位置 → 时刻。
列高 1056px = 24 小时(见 .tg-col 的 CSS),所以 1 分钟 = 1056/1440 px。 */
Array.prototype.forEach.call(document.querySelectorAll('.tg-col[data-new-at]'), function (col) {
col.addEventListener('click', function (ev) {
if (ev.target.closest && ev.target.closest('.tg-block')) { return; }
if (!<?= $canWrite && $writable ? 'true' : 'false' ?>) { return; }
var box = col.getBoundingClientRect();
var ratio = (ev.clientY - box.top) / Math.max(1, box.height);
var mins = Math.max(0, Math.min(24 * 60 - 30, Math.round(ratio * 1440 / 30) * 30));
var hh = String(Math.floor(mins / 60)).padStart(2, '0');
var mm = String(mins % 60).padStart(2, '0');
openNew(col.getAttribute('data-new-at'), hh, mm);
});
});
/* 从 ?new_at= 进来(比如别处链过来)→ 直接开弹窗并预填 */
var preset = <?= json_encode($newAt, JSON_UNESCAPED_UNICODE) ?>;
if (preset) {
var parts = String(preset).split(/[ T]/);
if (parts[0]) {
var hm = (parts[1] || '').split(':');
openNew(parts[0], hm[0] || '09', hm[1] || '00');
}
}
/* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
})();
/* ---- v24 第 14 批 #1:「跳到某一天」—— 图标按钮 + **按钮下方自绘的下拉日历** ----
原话(两条,都要满足):
· 「选择这个日期,不用弹窗,直接把日历选择,弹出在这个按钮下面就可以了」
· 「我点击上一个月,页面直接跳转,我还没选择日期呢」(严重 bug)
🔴 为什么**不用** Semi 的 DatePicker(上一版就是它):
Semi 的日历面板渲染在 **body 的 portal** 里(不在我们的下拉容器内),
而且它把"翻月/翻年"与"选中某天"混在同一个 onChange 回调里 ——
于是点"上一月"也被当成"选中了这一天",页面立刻提交跳转。
第三方控件的这个语义我们改不了;而这个交互又是"翻月不跳、点天才跳",
必须 100% 由我们自己定。所以这里自绘:
· « ‹ 年/月 › » → **只重绘面板**,绝不提交;
· 点某一天 → 立刻提交跳转(保留当前视图);
· 底部「跳到这天」→ 明确的确认入口(键盘/读屏用户也能用)。
⚠️ jump() 提交时带上当前 view(月的 ym / 周、日的 d),
由服务端决定该视图下这个日期落在哪一屏 —— 见 index.php 的 calendar 路由。 */
(function () {
var form = document.querySelector('.cal-jump');
var btn = document.getElementById('calJumpBtn');
var hid = document.getElementById('calJumpD');
var drop = document.getElementById('calJumpDrop');
if (!form || !btn || !hid) { return; }
var WD = ['一', '二', '三', '四', '五', '六', '日']; // 与月视图一致:周一起
var curY = 0, curM = 0; // 面板当前显示的年月
var sel = ''; // 已选日期('Y-m-d')
function ymd(y, m, d) {
return y + '-' + String(m).padStart(2, '0') + '-' + String(d).padStart(2, '0');
}
function jump(v) {
if (!v) { return; }
hid.value = v;
// 月视图还要带上 ym(服务端按月取数),否则会跳回默认月
var ym = document.getElementById('calJumpYM');
if (ym) { ym.value = String(v).slice(0, 7); }
form.submit();
}
function closeDrop() {
if (!drop) { return; }
drop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
drop.innerHTML = '';
}
/** 画一个月:**只动面板内容**,不做任何跳转 */
function paint() {
var head = document.createElement('div');
head.className = 'cjd-head';
head.innerHTML =
'<button type="button" class="cjd-nav" data-step="-12" title="上一年" aria-label="上一年">«</button>' +
'<button type="button" class="cjd-nav" data-step="-1" title="上一月" aria-label="上一月">‹</button>' +
'<span class="cjd-title"></span>' +
'<button type="button" class="cjd-nav" data-step="1" title="下一月" aria-label="下一月">›</button>' +
'<button type="button" class="cjd-nav" data-step="12" title="下一年" aria-label="下一年">»</button>';
head.querySelector('.cjd-title').textContent = curY + ' 年 ' + curM + ' 月';
var grid = document.createElement('div');
grid.className = 'cjd-grid';
for (var w = 0; w < 7; w++) {
var wd = document.createElement('div');
wd.className = 'cjd-wd';
wd.textContent = WD[w];
grid.appendChild(wd);
}
// 起算:本月 1 号是周几(周一=0 … 周日=6),前面补上月尾巴
var first = new Date(curY, curM - 1, 1);
var lead = (first.getDay() + 6) % 7;
var days = new Date(curY, curM, 0).getDate();
var today = new Date();
var todayStr = ymd(today.getFullYear(), today.getMonth() + 1, today.getDate());
var start = new Date(curY, curM - 1, 1 - lead);
for (var i = 0; i < 42; i++) {
var dt = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
var s = ymd(dt.getFullYear(), dt.getMonth() + 1, dt.getDate());
var cell = document.createElement('button');
cell.type = 'button';
cell.className = 'cjd-day';
if (dt.getMonth() + 1 !== curM) { cell.className += ' is-out'; }
if (s === todayStr) { cell.className += ' is-today'; }
if (s === sel) { cell.className += ' is-on'; }
cell.textContent = String(dt.getDate());
cell.setAttribute('data-date', s);
grid.appendChild(cell);
}
var foot = document.createElement('div');
foot.className = 'cjd-foot';
foot.innerHTML =
'<span class="cjd-sel small muted"></span>' +
'<button type="button" class="btn btn-sm cjd-today">今天</button>' +
'<button type="button" class="btn btn-sm cjd-go">跳到这天</button>';
foot.querySelector('.cjd-sel').textContent = sel ? ('已选 ' + sel) : '点一天就跳过去';
drop.innerHTML = '';
drop.appendChild(head);
drop.appendChild(grid);
drop.appendChild(foot);
// 翻月 / 翻年:**只重画**
// ⚠️ 必须 stopPropagation:paint() 会把面板里的节点换掉,等这次 click 冒泡到
// document 上的「点外面就关」时,e.target 已经不在 DOM 里了 ——
// `drop.contains(e.target)` 为假 → 面板当场被关掉(实测:点一下"上一月",
// 面板立刻消失,看起来像"按钮没反应")。
Array.prototype.forEach.call(drop.querySelectorAll('.cjd-nav'), function (b) {
b.addEventListener('click', function (e) {
e.stopPropagation();
var step = parseInt(b.getAttribute('data-step'), 10);
var t = new Date(curY, curM - 1 + step, 1);
curY = t.getFullYear();
curM = t.getMonth() + 1;
paint();
});
});
// 点某一天 → 跳转(这是**唯一**会自动提交的地方)
Array.prototype.forEach.call(drop.querySelectorAll('.cjd-day'), function (b) {
b.addEventListener('click', function (e) {
e.stopPropagation();
sel = b.getAttribute('data-date');
jump(sel);
});
});
// 「跳到这天」:给不需要/不方便"点了就跳"的场景一个明确入口
drop.querySelector('.cjd-go').addEventListener('click', function (e) {
e.stopPropagation(); jump(sel);
});
drop.querySelector('.cjd-today').addEventListener('click', function (e) {
e.stopPropagation();
var t = new Date();
sel = ymd(t.getFullYear(), t.getMonth() + 1, t.getDate());
jump(sel);
});
}
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (!drop) { return; }
if (!drop.hidden) { closeDrop(); return; }
var seed = String(hid.value || '').slice(0, 10);
var d0 = /^\d{4}-\d{2}-\d{2}$/.test(seed) ? new Date(seed + 'T00:00:00') : new Date();
if (isNaN(d0.getTime())) { d0 = new Date(); }
curY = d0.getFullYear();
curM = d0.getMonth() + 1;
sel = ymd(curY, curM, d0.getDate());
drop.hidden = false;
btn.setAttribute('aria-expanded', 'true');
paint();
});
// 点外面 / Esc 收起(下拉的常规行为)
document.addEventListener('click', function (e) {
if (!drop || drop.hidden) { return; }
if (drop.contains(e.target) || btn.contains(e.target)) { return; }
closeDrop();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeDrop(); }
});
})();
</script>