Files
FamilyBook/templates/calendar/index.php
T
laoyang 4fbac180e1 v51:旅程编辑保存后刷新 / 名册编辑页可改标签 / 手机号归一 / 反复点击带入时间+默认1h+分栏 / 日程设置拆分
用户 2026-10-15 六条。

① 旅程详情「编辑」保存后页面不刷新(改了题图/标题,页头纹丝不动)
   **两个 bug 串在一起**:
   A. 只换 `#nodeListHost`(行程列表),而标题/题图/时间城市都在那容器**外面**(服务端直出);
   B. 🔴 `form.id === 'tripForm'` **永远为假** —— HTML 表单把**同名具名控件**挂成自己的属性,
      `tripForm` 里有 `<input name="id">` ⇒ `form.id` 返回的是**那个 input**
      (`String(form.id)` = `[object HTMLInputElement]`)。同类还有 form.name/action/method。
   ⇒ 修法:`form.getAttribute('id')` 判型;tripForm(编辑旅程本体)→ **整页刷新**,
      nodeForm(行程增删改)→ 仍只换行程列表(那是 v41 用户明确要的"不整页跳")。

② 名册编辑页不能选标签("应该能编辑这个人的所有信息才对")
   `templates/people/form.php` 里压根没有标签 UI ⇒ 新增 `#ptBlock`:
   候选勾选(PersonTag::cloud)+ 自由输入(`name="tags"`,唯一提交源,勾选框只是快捷方式)。
   ⚠️ `PersonTag::setFor(家庭, **我**, 他, …)` —— owner 永远是"我",不影响全家的标签。
   ⚠️ 守卫按**字段存在与否**分流(array_key_exists),不是"值是否为空"——否则改自己时
      表单没这个字段,按"空值=清空"会把人家的标签抹掉。
   ⚠️ 校验失败的两条重渲染路径也要带上 `myTags`/`tagChoices`(取 `$_POST['tags']`)。

③ 导入的手机号带空格 → 编辑时被判"格式不对"("统一处理一下")
   真根因:主手机号正则 `^\+?[0-9\-]{6,20}$` **空格一律判错**,而补充电话那条收 `[0-9\- ]`
   ⇒ 同一个号码填主字段报错、填补充字段通过。
   ⇒ ① `Person::normPhone()` 去空格/短横/括号/**全角空格 U+3000**;
      ② 校验改成「先归一再判纯数字」——⚠️ 只把正则放宽到含 `\s` **不够**:
         PCRE 的 `\s`(无 /u/UCP)**不认全角空格**,通讯录导出里真有;
      ③ create/update/replaceContacts **入库前归一**(原来 hash 内部归一但**密文存原样**);
      ④ `Schema::ensure` 加**一次性迁移**(哨兵 `phone_normalized_v51`)把已入库的
         带分隔符号码解密→归一→写回(手机号是加密存的,SQL 里没法 LIKE/REPLACE)。

④ 周/日视图"只有第一次带入的时间是对的"
   真根因(真机给 `__fbRemount` 装 getter/setter 才抓到):起止字段藏在弹框里 ⇒
   boot 时 `upgradeRange()` 因"两端不可见"提前 return ⇒ 那一刻**只有 mountAntd** 给它赋值
   (会"重读原生值+重绘 antd");而**一打开弹框** `fbUpgradeRange()` 补跑 ⇒
   在尾部把它**重新赋成"只对齐原生下拉、不碰 antd"**的一版 ⇒ 此后 antd 控件永远停在第一次的值。
   ⇒ `upgradeRange()` 不再降级覆盖更强的实现,改成"把对齐下拉串在它前面"(`__fbRangeSync` 防自套)。

⑤ 默认结束 = 开始 + 1 小时;新增日程要按分栏错开(不再叠盖)
   · `setStart()` 里顺带设 ends_at = +1h(⚠️ 跨午夜顺延到次日;⚠️ 不捆进 fbSetDT,
     那个接口被"编辑已有日程"复用,强改会弄丢用户填的时长)。
   · 真根因:`putTimed()` 只把自己那条**整宽**铺上去,不知道同列已有日程
     (服务端整页渲染有分栏,但那是整页重绘时才算的)⇒ 前端 `relayoutColumn(col)`
     **照抄服务端 `conflictLanes`** 重排整列;模板加 `data-s`/`data-e`(**声明的起止分钟**,
     不能从 top/height 反推 —— 那是"实际显示结束",不是真实结束分钟)。

⑥ 日程设置下拉太长(日历本一多就把下面的设置挤出屏幕)→ 拆成"四行列表 + 显示设置独立页"
   新建 `templates/calendar/display.php` + 路由 `calendar/display`(默认视图 + 显示哪些本子);
   `_settings_menu.php` 从"内联表单"改成**纯列表四行**(显示设置/日历本管理/订阅管理/DAV 口令);
   ⚠️ `.cal-set-books` 原本 `max-height:42vh`("塞在下拉里"的妥协)⇒ 用 `.cal-set-page`
      作用域覆盖,否则整页里再套一层内滚 = 要滚两次。
   保存仍走既有 `calendar/prefs`(一个字段没改)。

验证:`_fb_v51_ui.py`(25 条真机)+ `_b51_falsify.py`(**12 注入 / 25 断言全抓**);
e2e 1511/0;`_fb_batch_e.py` 与 `familybook_deploy.sh` 里"设置菜单含 show_present"等
旧断言随 #6 搬到 display 页(意图一字不改);全流水线 PUSH_RC=0、部署 0 FAIL(含"第51批形态");
线上 10 文件 md5 逐字节一致。

⚠️ 排查纪律(已写进 pitfalls.md):
 · `form.id` 被同名控件遮蔽 —— 判型一律用 `getAttribute('id')`;
 · 反向验证的注入可能"打偏"(改的那处不是探针实际走的分支)——
   本轮 12 个注入有 2 个一开始没变红,都是注入打偏、不是断言弱。
2026-10-15 10:37:07 +08:00

1211 lines
70 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 ?? '');
// 能不能"点一下建日程":只读账号点了没反应,所以连 rt-writable 标记都不给。
// 🔴 v48(用户 2026-10-14 第 3 条):**十字光标已去掉**(CSS 里不再有 cursor:crosshair),
// 但 `rt-writable` 这个**类名保留** —— 它是"这个人能不能写"在 DOM 上的标记位,
// 点击建日程的判断逻辑与此无关(见底部 JS 的 $canWrite && $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 /* 🔴 v24 第 21 批 第 7 条:新建日程改成**弹框**(用户原话:
「日历中新建日程,无论是点击按钮还是点击日历中间,都是采用弹框的形式进行添加,
这样不影响当前日历的显示,添加完成后,动态修改当前视图,不要刷新。」
⚠️ 原来是**内联卡片**(`#calAddCard`,就长在日历网格的正上方,展开时把整个
月视图往下推)—— 用户点一下"新建",正在看的日历就被顶走了,
填完还得自己找回来。这正是他说的"影响当前日历的显示"。
⚠️ 改成弹框用**既有的浮层形态**(与选人弹窗、名册动作框同一套 `.upfield`
不是,是 `.rm-modal`/`.crop-overlay` 那一路视觉:遮罩 + 居中面板),
不引第三方库 —— 原生 `<dialog>` 在部分旧 WebView 上行为不一致,
而且我们已有的浮层已经解决了"点遮罩关、Esc 关、滚动锁"这几件事。
⚠️ 表单本身**一个字没动**(字段名/服务端契约/CSRF 全照旧),
只把它的容器从"页面上的一格"换成"浮层里的一块"。
`calendar_id` 那个 select 也保留(第 7 条要求"选择日历本,带上颜色",
见下面给每个 option 加的色块)。 */ ?>
<?php if ($canWrite && $writable): ?>
<div class="cal-modal" id="calAddCard" role="dialog" aria-modal="true"
aria-label="新建日程" hidden>
<div class="cal-modal-panel">
<div class="cal-modal-head">
<b>新建日程</b>
<button type="button" class="cal-modal-x" id="calAddCancel"
aria-label="关闭">✕</button>
</div>
<form method="post" action="<?= e(url('calendar/event_add')) ?>"
enctype="multipart/form-data" id="calAddForm">
<?= csrf_field() ?>
<input type="hidden" name="ym" value="<?= e($ym) ?>">
<input type="hidden" name="back_view" value="<?= e($view) ?>">
<input type="hidden" name="back_anchor" value="<?= e($anchor) ?>">
<div class="cal-modal-body">
<?php /* ⚠️ v44:`.mv-grid` 在这里就打开 —— 下面的「日历本」行
与「日期范围」字段块都要走观影式排版(标签右对齐 + 冒号)。
原来 `.mv-grid` 是包在字段块那一小段里的,日课本在它**外面**,
于是"日历本:下拉"落不到这套排版上(正是用户报的"不在一行")。 */ ?>
<div class="mv-grid">
<?php /* 🔴🔴 v44(用户 2026-10-12 第 1 条):「新建日程,放在哪本,改成日历本后面加冒号。
现在这句话和日历本下拉框还有前面的颜色,仍然不在一行,
我建议把颜色放在下拉框里面,就跟电影的影院选择一样,
让日历本这三个字和下拉框在一条线上。」
三件事:
① 标签改「日历本」(用户点名要这三个字),冒号由观影式排版的
`.mv-row > .mv-lb::after` 给(与观影/任务一致);
② **颜色不再是下拉框外的一个色块**,而是像"影院选择"那样
**画进下拉里**(每行左边一个该本子的色点)。
原生 `<option>` 画不出元素 ⇒ 换成**自定义下拉**
(复用全站的 `.cs` 组件:按钮 + 弹出列表 + 搜索),
真值仍写进隐藏的 `<input name="calendar_id">`(表单契约不变)。
③ 因为换成了一个自绘的下拉,三者天然在同一行 ——
不再有"标签一行、色块+下拉下一行"的错位。
⚠️ 无 JS 兜底:`.cs` 里**仍然保留**一个原生 `<select name="calendar_id">`
(视觉收起、但它是真的提交字段)—— JS 没跑时它就是那个下拉,
与 `_datetime.php` 的"隐藏 date 当值载体"是同一套思路
(v43 那条"没有盒子的元素定位无效"的教训:这里用 `position:absolute;
width:1px` 而非 `display:none`,保留可聚焦、可提交)。
⚠️ 组件 id 保持 `cinemaPicker/csBtn/csMenu/csDot/csText` **不合适**
(那是观影的编号)—— 用 `calPicker/cb*` 一套独立的,
免得将来两处互相 getElementById 抢。
⚠️ e2e / 断言里按 `#ce-cal` 定位的写法**必须继续成立**:
原生 select 的 id 保持 `ce-cal`(只是被视觉收起)。 */ ?>
<?php /* 🔴 v24 第 21 批 第 7 条:**默认必须选"我自己的普通日历本"**。
原来直接取 `$writable[0]` —— 而顺序是按 id,观影 / 旅行两本
通常排在最前面。于是用户打开"新建日程"、直接打字、点"加上",
建出来的其实是一条**观影记录**(或旅程)。
这不是"显示不对",是**建错东西**,而且事后很难发现
—— 用户以为日历里多了条日程,其实观影列表里多了一条记录。
所以:优先把自己建的 kind='custom' 本子排在前面当默认;
没有普通本时才退到第一个可写的本子。 */
$firstCal = null;
foreach ($writable as $c) {
if ((string)($c['kind'] ?? '') === 'custom') { $firstCal = $c; break; }
}
if ($firstCal === null) { $firstCal = $writable ? $writable[0] : null; }
/* ⚠️ 模板里**再兜一道**"快照":路由只会在**页面级**补一次本子,
而这张表单是页面渲染时定格的 —— 万一补本失败(DB 异常等),
这里至少还能看出"没有可写的普通本",不至于把"选中了观影本"
当成正常状态。 */
$hasPersonal = $firstCal !== null
&& (string)($firstCal['kind'] ?? '') === 'custom'; ?>
<div class="mv-row">
<span class="mv-lb">日历本</span>
<div class="cs cal-book-cs" id="calPicker" data-open="0">
<button type="button" class="cs-btn" id="cbBtn" aria-haspopup="listbox" aria-expanded="false">
<span class="cs-dot" id="cbDot"
style="background: <?= e((string)($firstCal['color'] ?? '#4A7C82')) ?>;"></span>
<span class="cs-text" id="cbText"><?= e((string)($firstCal['name'] ?? '未选择')) ?></span>
<span class="cs-caret" aria-hidden="true">▾</span>
</button>
<div class="cs-menu" id="cbMenu" hidden role="listbox">
<div class="cs-list" id="cbList">
<?php foreach ($writable as $c): $ro = \App\Calendar::isReadonlyKind((string)$c['kind']);
$isDef = $firstCal && (int)$c['id'] === (int)$firstCal['id']; ?>
<?php /* v24 第 21 批 第 7 条:纯派生的本子(农历/节假日/传统节日/节气)
列出来但**禁用** —— 直接不列的话,用户会以为"我的本子丢了";
列为可选项又会让他白选一次再吃一个错误提示。
禁用 + 一句 title 说明,是这个取舍里最不费解的一种。
观影 / 旅行**不禁用**:它们可写(加一条 = 建一条观影/旅程)。 */ ?>
<button type="button" class="cs-item<?= $ro ? ' is-disabled' : '' ?>"
role="option"<?= $ro ? ' disabled' : '' ?>
data-id="<?= (int)$c['id'] ?>"
data-color="<?= e((string)$c['color']) ?>"
data-text="<?= e((string)$c['name']) ?>"
<?= $ro ? 'title="系统自动生成,不能直接加日程"' : '' ?>>
<span class="cs-dot" style="background: <?= e((string)$c['color']) ?>;"></span>
<span><?= e((string)$c['name']) ?><?= $ro ? '(自动生成)' : '' ?></span>
</button>
<?php endforeach; ?>
</div>
<?php /* 一个都没匹配上时露出来(平时 hidden,不占高度) */ ?>
<div class="cs-empty" id="cbEmpty" hidden>没有匹配的日历本</div>
</div>
<?php /* ⚠️⚠️ 这是**真的提交字段**(不是"只给 JS 用"的影子):
自定义下拉只是它的外观;JS 没跑时它就是那个原生下拉。
视觉收起用 position:absolute + 1px(**不用 display:none**)——
后者会让表单校验时不可聚焦(v37 那次的坑)。
桌面无 JS 时它贴在按钮下方 1px 处、可点,仍能选。 */ ?>
<select class="select cal-book-native" id="ce-cal" name="calendar_id" aria-label="日历本">
<?php foreach ($writable as $c): $ro = \App\Calendar::isReadonlyKind((string)$c['kind']);
$isDef = $firstCal && (int)$c['id'] === (int)$firstCal['id']; ?>
<option value="<?= (int)$c['id'] ?>"
data-color="<?= e((string)$c['color']) ?>"
<?= $isDef ? ' selected' : '' ?>
<?= $ro ? 'disabled title="系统自动生成,不能直接加日程"' : '' ?>><?= e((string)$c['name']) ?><?= $ro ? '(自动生成)' : '' ?></option>
<?php endforeach; ?>
</select>
<?php /* 兼容旧断言/旧 JS:外面那个独立色块曾经叫 ce-cal-dot。
现在颜色已经画进下拉里、不再需要它 —— 保留一个**隐藏**的同名元素,
免得旧探针 document.getElementById('ce-cal-dot').style 直接抛错。 */ ?>
<span class="cal-book-dot" id="ce-cal-dot" hidden
style="background: <?= e((string)($firstCal['color'] ?? '#4A7C82')) ?>;"></span>
</div>
</div>
<?php if (!$hasPersonal): ?>
<span class="tip" style="display:block;margin-top:6px">
⚠️ 你还没有**自己的日程本**,上面选的是一本"派生本"
(加进去会变成观影 / 旅程记录)。去
<a href="<?= e(url('calendar/calendars')) ?>">日历本</a> 建一本吧。
</span>
<?php endif; ?>
<?php /* v24 第 6 条:字段按 iCalendar / CalDAV 的 VEVENT 定(见 _event_fields.php)。
新建时「全天」默认**不勾**(用户原话)。开始时间默认:
带了 new_at 就用它(点日历的那一格),否则今天 09:00。
🔴 v43(用户 2026-10-11 第 5 条):「新建日程页面的排版还是不对,
也是一样的:把标题放在前面,后面加冒号,再跟上输入框。」
⇒ 用观影/行程那套 `.mv-grid`(标签 62px 右对齐 + CSS ::after 冒号 +
控件占满剩余宽度)包住字段块。
`$evMvStyle = true` 让字段块输出 `.mv-row` / `.mv-lb`
而不是 `.field` / `<label>`;编辑页不传,保持原两列。 */ ?>
<?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;
/* ⚠️ v44:`.mv-grid` 容器**已经在上面日课本那一行之前打开了**
(日课本自己也要走观影式排版)—— 这里只 require 字段块,
不要再包一层,否则会出现嵌套的第二个 `.mv-grid`。 */
$evMvStyle = true; // 观影式排版(用户第 5 条)
require __DIR__ . '/_event_fields.php';
unset($evMvStyle);
?>
</div><?php /* /mv-grid */ ?>
</div><?php /* /cal-modal-body */ ?>
<div class="cal-modal-foot">
<span class="cal-modal-tip" id="calAddTip"></span>
<?php /* 🔴 v37 用户第 5 条:「"加上"和"取消"两个按钮在右上角,离得特别远,
根本看不到。应该把它们放在下方…」
真根因(真机量出来的,不是猜):
原来这里**多写了一个 `</div>`** —— `.cal-modal-panel` 被提前闭合、
跟着又被多关一层,于是 `<div class="cal-modal-foot">` 跑到了
**面板外面**(DOM 上它是 `.cal-modal` 的直接子元素)。
面板 `max-height` 一卡,底栏就被挤到面板下方 → 用户看不到。
另有 CSS 一层:`.cal-modal-panel > form` 原先不是 flex 列,
`.cal-modal-body{flex:1 1 auto}` 因此不起作用(两处都已修)。
⚠️ 提交键用 `form="calAddForm"` **显式关联**:多一层/少一层 DOM 的
意外都不会再让它失效(它本来就在 form 内部,但显式写更稳)。 */ ?>
<button class="btn btn-sm" type="submit" form="calAddForm" id="calAddSubmit">加上</button>
<button class="btn btn-ghost btn-sm" type="button" id="calAddCancel2">取消</button>
</div>
</form>
</div>
</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): ?>
<?php
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<a class="cal-ev is-allday" data-cal="<?= (int)$ev['cal'] ?>"
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'
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 /* 🔴 v30 第 4 条:时间冲突的日程**错开画**(不再堆叠)。
用户原话:「如果两个事件时间上有冲突了,现在是堆叠在一起,应该错开,
显示两个色块,如果三个冲突了,就是三个色块,错开显示。」
left/width 由 App\Calendar::conflictLanes 算好(算法借鉴 SyncCalendar
的 TimelineUtil:贪心分列,列数 = 最大同时重叠数)。
⚠️ 用**内联 left/width 百分比**而不是 CSS 类:
列数 1/2/3/4 是运行时才知道的,类名会爆炸;
而且模板已经在内联 top/height 了,同一套坐标系最不容易错。 */ ?>
<?php foreach (($tgrid['timed'][$i] ?? []) as $ev): ?>
<?php /* 🔴 v43(用户第 6 条):点条目弹详情浮层(不再直接跳页)。
数据由 fb_entry_json() 统一编(三个入口共用一个函数)。
「详细信息」跳该来源的详情页;「编辑」取该来源的编辑表单片段。
⚠️ 编辑地址一律带可写判断:只读的(农历/节假日)不给「编辑」。 */ ?>
<?php
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evDet = (string)($ev['href'] ?? '');
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
// trip 的条目指向"某个旅程里的某段行程",编辑器在旅程详情页里 —— 这里不给「编辑」
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<?php
/* 🔴 v51(用户 2026-10-15 第 3 条):分栏(冲突错开)**在浏览器里重算**要用到
每条色块**声明的起止分钟**(服务端算分栏时用的就是这一对数)。
⚠️ 必须取 `time` / `end_time`,**不能**从 top/height 反推 ——
服务端的 `top/height` 是按**"实际显示结束"**算的(没填结束时可能被
同列下一条的起点截断),那不是它的真实结束分钟;拿它去分栏会把冲突判错。
⚠️ `data-e` 缺失 = "没填结束" ⇒ 前端按 +30′ 处理(与服务端 `$probe` 同口径)。 */
preg_match('/^(\d{1,2}):(\d{2})/', (string)($ev['time'] ?? ''), $mS);
$evS = $mS ? ((int)$mS[1] * 60 + (int)$mS[2]) : 0;
preg_match('/^(\d{1,2}):(\d{2})/', (string)($ev['end_time'] ?? ''), $mE);
$evE = $mE ? ((int)$mE[1] * 60 + (int)$mE[2]) : null;
if ($evE !== null && $evE <= $evS) { $evE = null; }
?>
<a class="tg-block" data-cal="<?= (int)$ev['cal'] ?>"
data-s="<?= (int)$evS ?>"<?= $evE !== null ? ' data-e="' . (int)$evE . '"' : '' ?>
data-fb-entry='<?= fb_entry_json($ev, $evDet, $evEdit) ?>'
style="background: <?= e((string)$ev['color']) ?>;
top: <?= (float)$ev['top'] ?>%; height: <?= (float)$ev['height'] ?>%;
left: calc(2px + <?= (float)($ev['left'] ?? 0) ?>%);
width: calc(<?= (float)($ev['width'] ?? 100) ?>% - 4px);"
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): ?>
<?php
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<a class="cal-ev<?= empty($ev['all_day']) ? '' : ' is-allday' ?>"
data-cal="<?= (int)$ev['cal'] ?>"
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'
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'); /* 头部 ✕ */
var cancel2 = document.getElementById('calAddCancel2'); /* 底部「取消」 */
if (btn && card) {
btn.addEventListener('click', function () { openNew(null, null, null); });
}
/* 关:✕ / 取消 / 点遮罩 / Esc 四条路都要通(与站内其它浮层一致)。
⚠️ 点遮罩才关、点面板内不关 —— 用 e.target === card 判,不用"点哪儿都关"。 */
function closeNew() { if (card) { card.hidden = true; } }
if (cancel) { cancel.addEventListener('click', closeNew); }
if (cancel2) { cancel2.addEventListener('click', closeNew); }
if (card) {
card.addEventListener('click', function (e) { if (e.target === card) { closeNew(); } });
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && card && !card.hidden) { closeNew(); }
});
/* ---- 用户第 5 条:在日历里点一下 → 弹新建,开始时间 = 点的位置 ----
月视图:一格 = 一天 → 开始时间用那天 09:00("当日默认"比 00:00 更像个日程)。
周/日视图:按鼠标在**那一列里的纵向位置**反算时刻,最接近的半小时 ——
点在"下午三点那一段"就该得到 15:00,这是"点哪儿建哪儿"的全部意义。 */
var startField = document.querySelector('.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) {
var H = (hh === null || hh === undefined || hh === '') ? '09' : String(hh);
var M = (mm === null || mm === undefined || mm === '') ? '00' : String(mm);
// ⚠️ 优先走全局接口:它会把原生字段与 Semi/antd 控件**一起**更新。
// 直接写 startDate.value 的话,非受控控件显示不会跟着变 ——
// 用户会看到"我点了 15:00,控件里还写着 09:00"(提交的却是 15:00)。
var okStart = (typeof window.fbSetDT === 'function'
&& window.fbSetDT('starts_at', dateStr + ' ' + H + ':' + M + ':00'));
if (!okStart) {
// 兜底:Semi/antd 还没加载完(或加载失败)→ 原生控件照旧能用
if (!startDate) return;
startDate.value = dateStr;
if (hh !== null && hh !== undefined && startHour) { startHour.value = hh; }
if (mm !== null && mm !== undefined && startMin) { startMin.value = mm; }
if (startSec) { startSec.value = '00'; }
}
/* 🔴🔴 v51(用户 2026-10-15 第 2 条):「我们的时间范围**默认给它设 1 个小时**,
就是说你点了之后带进来的是 8 点钟,那么它结束时间应该是当天的 9 点钟,
就按照 1 个小时把结束时间也给它放上去。」
⇒ 带入开始时间时**同时**把结束设为「开始 + 1 小时」。
⚠️ 跨午夜要顺延到次日(23:30 点下去 → 次日 00:30),不能让结束时间
在日期上跑回"比开始还早的今天"(那种提交上去是负时长)。
⚠️ 结束时间的设置**只在这里**做(用户"点了某一处"这个动作)——
不要捆进 fbSetDT:那个接口也被"编辑已有日程"复用,
把它的结束时间也强改成 +1h 会把用户原本填的时长弄丢。 */
var st = new Date(dateStr + 'T' + H + ':' + M + ':00');
if (!isNaN(st.getTime())) {
st.setHours(st.getHours() + 1);
var pad2 = function (n) { return (n < 10 ? '0' : '') + n; };
var eDate = st.getFullYear() + '-' + pad2(st.getMonth() + 1) + '-' + pad2(st.getDate());
var eStr = eDate + ' ' + pad2(st.getHours()) + ':' + pad2(st.getMinutes()) + ':00';
if (typeof window.fbSetDT !== 'function' || !window.fbSetDT('ends_at', eStr)) {
/* 兜底:按 data-dt 里的 JSON `name` 找结束端点(**不能**用 `[data-dt*="ends_at"]`
—— 那个属性是一整段 JSON,里面含 `name` 键,朴素子串匹配能中但语义不对,
将来加个字段就误命中)。 */
var endField = null;
Array.prototype.forEach.call(document.querySelectorAll('.dt-input[data-dt]'), function (bx) {
if (endField) { return; }
var cfg = {};
try { cfg = JSON.parse(bx.getAttribute('data-dt') || '{}'); } catch (e) {}
if (cfg.name === 'ends_at') { endField = bx; }
});
if (endField) {
var ed = endField.querySelector('input.dt-date');
var eh = endField.querySelector('select.dt-hour');
var em = endField.querySelector('select.dt-minute');
if (ed) { ed.value = eDate; }
if (eh) { eh.value = pad2(st.getHours()); }
if (em) { em.value = pad2(st.getMinutes()); }
}
}
}
}
/**
* 打开新建日程的**弹框**(v24 第 21 批 第 7 条)。
*
* ⚠️ 不再 `scrollIntoView` —— 那是"内联卡片"时代为了让用户找到卡片才需要的动作;
* 浮层固定在视口中央,底下的日历**一动不动**(这正是用户要的
* "不影响当前日历的显示")。
* ⚠️ dateStr 传 null 表示"从按钮进来":那时**不要再覆盖**已填的时间
* (用户可能刚填完改主意关掉、又点开;重置会把他填的东西抹掉)。
* 只有"点日历某一格/某一列"才带着具体时间进来,那次才 setStart。
*/
function openNew(dateStr, hh, mm) {
if (!card) return;
card.hidden = false;
if (dateStr) { setStart(dateStr, hh, mm); }
// 标题是第一个输入框,直接把光标放过去 —— 打开就能打字
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');
}
}
/* ============================================================
v24 第 21 批 第 7 条:日历本色块 + **无刷新提交 + 动态改当前视图**
------------------------------------------------------------
用户原话:「添加完成后,动态修改当前视图,不要刷新。」
⚠️ 做法:拦掉表单提交 → fetch POST(带 X-Requested-With,服务端据此回 JSON)
→ 拿到新日程的 {starts_at, ends_at, title, all_day, color, href, cal} →
**直接往当前视图里插入那一条**(月视图找对应格子塞小条、周/日视图按时间
算 top/height 插块),然后关掉弹框。整个过程中页面不重载、滚动位置不动。
⚠️ 任何一步不成立(拿不到 JSON / 字段缺 / 当前视图不好插)→ **退回原生提交**。
"插入失败还假装成功"是最坏的结果:用户以为加上了、切到别的月份才发现没有。
⚠️ 插入用的定位方式必须与**服务端渲染的完全一致**(月视图按 data-new-at 找格子、
周/日视图按"24h = 100%"算 top/height),否则刷新一次小条就会跳位置。
这里刻意照抄模板里的选择器与算式,别另发明一套。
============================================================ */
(function () {
var calDot = document.getElementById('ce-cal-dot');
var calSel = document.getElementById('ce-cal');
if (calDot && calSel) {
var syncDot = function () {
var o = calSel.options[calSel.selectedIndex];
var c = o ? o.getAttribute('data-color') : '';
if (c) { calDot.style.background = c; }
};
calSel.addEventListener('change', syncDot);
syncDot();
}
/* ============================================================
🔴 v44(用户 2026-10-12 第 1 条):日历本自绘下拉(色点画进下拉里)
------------------------------------------------------------
与观影的影院下拉同一套交互,但**状态写在选中的原生 `<select>` 上**
(写 `.value` + 派发 change)—— 这样无 JS 兜底、既有断言(读 `#ce-cal`)
与"提交契约"全都不用改:自定义下拉只是它的**外观**。
⚠️ 菜单 `.cs-menu` 是 `position:absolute`,而它**居中面板的滚容器里**
(`.cal-modal { overflow-y:auto }`)。要看它在低端会不会被裁掉 ——
真机几何断言里会量(见论文式验证)。
============================================================ */
(function () {
var picker = document.getElementById('calPicker');
var sel = document.getElementById('ce-cal');
if (!picker || !sel) { return; }
var btn = document.getElementById('cbBtn');
var menu = document.getElementById('cbMenu');
var dot = document.getElementById('cbDot');
var text = document.getElementById('cbText');
var list = document.getElementById('cbList');
if (!btn || !menu || !dot || !text || !list) { return; }
/* 把 options 重建成列表项 —— 单一真值来源是那个 `<select>`,
所以这里不需要模板另给一份数据、也不会两边不同步。 */
function build() {
list.innerHTML = '';
Array.prototype.forEach.call(sel.options, function (o) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'cs-item' + (o.disabled ? ' is-disabled' : '');
b.setAttribute('role', 'option');
if (o.disabled) { b.disabled = true; b.title = o.getAttribute('title') || ''; }
b.setAttribute('data-id', o.value);
b.setAttribute('data-color', o.getAttribute('data-color') || '');
b.setAttribute('data-text', o.textContent || '');
var d = document.createElement('span');
d.className = 'cs-dot';
d.style.background = o.getAttribute('data-color') || '';
var s = document.createElement('span');
s.textContent = o.textContent || '';
b.appendChild(d); b.appendChild(s);
list.appendChild(b);
});
}
function paint() {
var o = sel.options[sel.selectedIndex];
if (!o) { return; }
text.textContent = o.textContent || '';
var c = o.getAttribute('data-color') || '';
if (c) { dot.hidden = false; dot.style.background = c; } else { dot.hidden = true; }
}
function closeMenu() {
menu.hidden = true;
btn.setAttribute('aria-expanded', 'false');
picker.setAttribute('data-open', '0');
}
function openMenu() {
menu.hidden = false;
btn.setAttribute('aria-expanded', 'true');
picker.setAttribute('data-open', '1');
}
build();
paint();
/* ⚠️ 原生 select 的 change 会由别处(旧 syncDot、无 JS 路径)触发,
这里也监听一次,保证显示与真值永远一致。 */
sel.addEventListener('change', paint);
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (menu.hidden) { openMenu(); } else { closeMenu(); }
});
menu.addEventListener('click', function (e) {
var it = e.target.closest ? e.target.closest('.cs-item') : null;
if (!it || it.disabled) { return; }
e.stopPropagation();
sel.value = it.getAttribute('data-id');
sel.dispatchEvent(new Event('change', { bubbles: true }));
closeMenu();
});
/* 点空白处收起(用 document 监听 + 判定 inside,不额外造遮罩节点)
⚠️ 必须在**捕获阶段**判定:菜单项的 click 会 stopPropagation,
但那是冒泡阶段 —— 捕获阶段先于它,这里判"点在不在 picker 里"
就够精确了(点在菜单里 → 不收)。 */
document.addEventListener('click', function (e) {
if (menu.hidden) { return; }
if (picker.contains(e.target)) { return; }
closeMenu();
}, true);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !menu.hidden) { closeMenu(); }
});
})();
var form = document.getElementById('calAddForm');
var tip = document.getElementById('calAddTip');
if (!form || !card || !window.fetch) { return; }
function sayErr(msg) {
if (tip) { tip.textContent = msg; tip.className = 'cal-modal-tip err'; }
}
/** 月视图:往 data-new-at="YYYY-MM-DD" 那个格子里塞一条小条 */
function putMonth(ev) {
var day = String(ev.starts_at || '').slice(0, 10);
var cell = document.querySelector('.cal-cell[data-new-at="' + day + '"]');
if (!cell) { return false; }
var box = cell.querySelector('.cal-evs');
if (!box) { return false; }
var a = document.createElement('a');
a.className = 'cal-ev' + (ev.all_day ? ' is-allday' : '');
a.setAttribute('data-cal', String(ev.cal || ''));
a.style.background = ev.color || '';
a.setAttribute('href', ev.href || '#');
a.setAttribute('title', ev.title || '');
/* ⚠️ 时间前缀的形状必须与服务端一致(<b>HH:MM</b> 后面一个空格)——
不一致的话刷新一次就会看出差别。 */
if (ev.time) {
var b = document.createElement('b');
b.textContent = ev.time;
a.appendChild(b);
a.appendChild(document.createTextNode(' '));
}
a.appendChild(document.createTextNode(ev.title || ''));
/* 🔴 插到**正确的时间位置**,不能 append 到末尾。
为什么必须按时间插:服务端只渲染前 3 条(第 4 条起收进"+N 条"),
而"哪 3 条"是按**时间顺序**取的。若这边一律 append:
· 新加一条**早上 7 点**的日程,它会被排到最后 → 落到"+N 条"里看不见,
可刷新一次又会跑到第一位 —— 这正是"插进去和刷新后长得不一样"。
⚠️ 判据与服务端渲染同一套:先比 <b>HH:MM</b>,没有时间前缀的(全天)排前面
(与 Calendar::month 给 all_day 的处理一致),再比标题兜底稳定性。 */
var newTime = ev.time || '';
var placed = false;
var existing = Array.prototype.slice.call(box.querySelectorAll('.cal-ev'));
for (var k = 0; k < existing.length; k++) {
var tEl = existing[k].querySelector('b');
var tTxt = tEl ? tEl.textContent.trim() : '';
if (tTxt === '' && newTime !== '') { continue; } /* 空时间(全天)在前 */
if (tTxt !== '' && newTime === '') { box.insertBefore(a, existing[k]); placed = true; break; }
if (tTxt > newTime) { box.insertBefore(a, existing[k]); placed = true; break; }
}
if (!placed) { box.appendChild(a); }
/* 再按"只显示前 3 条"收口,与服务端渲染对齐(否则会多出一条)。 */
var links = box.querySelectorAll('.cal-ev');
var more = box.querySelector('.cal-more');
for (var j = 0; j < links.length; j++) {
links[j].style.display = (j < 3) ? '' : 'none';
}
if (links.length > 3) {
var extra = links.length - 3;
if (!more) {
more = document.createElement('span');
more.className = 'cal-more';
box.appendChild(more);
}
more.textContent = '+' + extra + ' 条';
} else if (more) {
more.parentNode.removeChild(more);
}
return true;
}
/* 🔴🔴 v51(用户 2026-10-15 第 3 条):「添加一个日程之后,只是把这个日程放在了
周视图或者是日视图上面。如果它这个时间段已经有日程了,它会**直接盖在上面**,
却并没有按照**分栏**的方式左右显示。这个需要再调整一下。」
真根因:`putTimed()` 只把自己那条色块按 top/height **整宽铺上去**,
完全不知道同一列里已有的日程 —— 于是两条重叠时后加的直接压在先有的上面
(服务端渲染时是有分栏的:`App\Calendar::conflictLanes`,但那是**整页重绘**时才算的)。
⇒ 修法:**在浏览器里重算整列的分栏**(与服务端同一套算法),插入后重排该列。
为什么"重算整列"而不是"给新块腾个位置":
新日程可能同时与**多条**已有日程冲突(比如插进一个 3 段重叠的时段),
只挪新块会让已有多条的宽度/位置也不对(它们现在是 2 列、重算后该是 3 列)
—— 必须让整列一起重排才是自洽的。
⚠️ 算法**照抄** app/Calendar.php 的 conflictLanes(贪心分列 + 分组复列):
两边算错一个边界,刷新一次位置就会跳。
⚠️ 高度仍按各自"声明的起止"算(没结束的用 +30′)—— 与服务端那套一致。
(服务端在"没填结束"时还会拿同列下一条的起点当视觉结束;这里简化成 +30′,
差别只在"未填结束"这一种情况,且插入后用户一刷新就归位。 */
function minsAttr(el, attr) {
var v = el.getAttribute(attr);
if (v === null || v === '') { return null; }
var n = parseInt(v, 10);
return isNaN(n) ? null : n;
}
/** 重算某一列里**所有** .tg-block 的分栏(left/width)与 top/height */
function relayoutColumn(col) {
var blocks = Array.prototype.slice.call(col.querySelectorAll('.tg-block'));
if (!blocks.length) { return; }
// ① 归一成 [{el, s, e}](缺 data-e → +30′;缺 data-s → 从 top 反推兜底)
var rows = [];
blocks.forEach(function (el) {
var s = minsAttr(el, 'data-s');
if (s === null) {
var tp = parseFloat(el.style.top) || 0;
s = Math.round(tp / 100 * 1440);
}
var e = minsAttr(el, 'data-e');
if (e === null || e <= s) { e = s + 30; }
rows.push({ el: el, s: s, e: Math.min(e, 1440) });
});
// ② 按开始时间升序(同开始则长的在前)—— 与服务端 usort 同序
rows.sort(function (a, b) { return a.s - b.s || b.e - a.e; });
// ③ 划分冲突组(与组内最晚结束仍重叠就并组)
var groups = [], cur = [], endMax = -1;
rows.forEach(function (r) {
if (cur.length && r.s >= endMax) { groups.push(cur); cur = []; endMax = -1; }
cur.push(r);
endMax = Math.max(endMax, r.e);
});
if (cur.length) { groups.push(cur); }
// ④ 组内贪心分列(每列记最后结束分钟,放进编号最小的可用列)
groups.forEach(function (g) {
var laneEnd = [], laneOf = [];
g.forEach(function (r, k) {
var lane = -1;
for (var li = 0; li < laneEnd.length; li++) {
if (laneEnd[li] <= r.s) { lane = li; break; }
}
if (lane < 0) { lane = laneEnd.length; laneEnd.push(r.e); }
else { laneEnd[lane] = r.e; }
laneOf[k] = lane;
});
var cols = Math.max(1, laneEnd.length);
g.forEach(function (r, k) {
var left = laneOf[k] / cols * 100, width = 100 / cols;
r.el.style.left = 'calc(2px + ' + left.toFixed(4) + '%)';
r.el.style.width = 'calc(' + width.toFixed(4) + '% - 4px)';
r.el.style.top = (r.s / 1440 * 100).toFixed(4) + '%';
r.el.style.height = ((Math.max(15, r.e - r.s)) / 1440 * 100).toFixed(4) + '%';
});
});
}
/** 周/日视图:按"列高 = 24 小时"算 top/height 插时间块 */
function putTimed(ev) {
if (ev.all_day) {
var ad = document.querySelector('.tg-adcell');
if (!ad) { return false; }
var box2 = document.querySelector('.tg-allday');
var col = null;
if (box2) {
/* 找到"那一天"那一列:列的顺序与 .tg-dayname 一致,
用 data-new-at 反查(服务端给每列写了当天日期)。 */
var cols = Array.prototype.slice.call(document.querySelectorAll('.tg-col[data-new-at]'));
var day2 = String(ev.starts_at || '').slice(0, 10);
var idx = -1;
cols.forEach(function (c, i) { if (c.getAttribute('data-new-at') === day2) { idx = i; } });
if (idx >= 0) {
var cells = document.querySelectorAll('.tg-adcell');
col = cells[idx] || null;
}
}
var host = col || ad;
if (!host) { return false; }
var a2 = document.createElement('a');
a2.className = 'cal-ev is-allday';
a2.setAttribute('data-cal', String(ev.cal || ''));
a2.style.background = ev.color || '';
a2.setAttribute('href', ev.href || '#');
a2.setAttribute('title', ev.title || '');
a2.textContent = ev.title || '';
host.appendChild(a2);
return true;
}
var cols3 = Array.prototype.slice.call(document.querySelectorAll('.tg-col[data-new-at]'));
var day3 = String(ev.starts_at || '').slice(0, 10);
var ci = -1;
cols3.forEach(function (c, i) { if (c.getAttribute('data-new-at') === day3) { ci = i; } });
if (ci < 0) { return false; }
var target = cols3[ci];
var minsOf = function (t) {
var m = String(t || '').match(/[ T](\d{1,2}):(\d{1,2})/);
return m ? (+m[1]) * 60 + (+m[2]) : 0;
};
var st = minsOf(ev.starts_at), en = minsOf(ev.ends_at);
if (en <= st) { en = st + 30; } /* 没填结束就给半小时 —— 与服务端一致 */
var top = (st / 1440) * 100, hgt = ((en - st) / 1440) * 100;
var blk = document.createElement('a');
blk.className = 'tg-block';
blk.setAttribute('data-cal', String(ev.cal || ''));
/* 🔴 v51:带上"声明的起止分钟",重算分栏要用(服务端渲染的同名属性)。
⚠️ 与服务端口径一致:结束缺失/倒挂 → 不带 data-e(按 +30′ 处理)。 */
blk.setAttribute('data-s', String(st));
var declE = minsOf(ev.ends_at);
if (declE > st) { blk.setAttribute('data-e', String(declE)); }
blk.style.background = ev.color || '';
blk.style.top = top.toFixed(4) + '%';
blk.style.height = hgt.toFixed(4) + '%';
blk.setAttribute('href', ev.href || '#');
blk.setAttribute('title', ev.title || '');
blk.textContent = ev.title || '';
target.appendChild(blk);
/* 🔴 v51(第 3 条):插完**重排整列** —— 否则这条会整宽盖在已有日程上(用户报的现象)。 */
relayoutColumn(target);
return true;
}
function insert(ev) {
/* 先看当前视图是哪一种:页面上有 .cal-grid 就是月视图;
有 .tg-col 就是周/日视图。两者都不在(理论上不会)→ 退回刷新。 */
if (document.querySelector('.cal-grid')) { return putMonth(ev); }
if (document.querySelector('.tg-col[data-new-at]')) { return putTimed(ev); }
return false;
}
form.addEventListener('submit', function (e) {
if (form.getAttribute('data-native-submit') === '1') { return; }
e.preventDefault();
var sub = document.getElementById('calAddSubmit');
if (sub) { sub.disabled = true; }
if (tip) { tip.textContent = ''; tip.className = 'cal-modal-tip'; }
fetch(form.getAttribute('action'), {
method: 'POST', body: new FormData(form), credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json' }
}).then(function (r) { return r.json().catch(function () { return null; }); })
.then(function (d) {
if (sub) { sub.disabled = false; }
if (d && d.ok && d.event) {
var ok = false;
try { ok = insert(d.event); } catch (err) { ok = false; }
if (ok) {
/* 观影 / 旅行本是**派生**的(内容来自观影/旅程表):AJAX 时服务端
不再自动跳详情页(跳走就离开了日历),改在提示行给一个链接。 */
if (d.goto && tip) {
tip.className = 'cal-modal-tip';
tip.textContent = (d.msg || '已加上。') + ' ';
var ga = document.createElement('a');
ga.href = d.goto;
ga.textContent = '去看详情 →';
tip.appendChild(ga);
setTimeout(function () {
if (tip) { tip.textContent = ''; }
}, 6000);
}
closeNew();
/* 重置"只填一次的字段",但**保留**日历本的选择(用户往往连加几条)。
⚠️ 标题必须清掉,否则下一条会带着上一条的标题(很容易漏掉)。 */
var t = document.getElementById('ev-title');
var lo = document.getElementById('ev-location');
var no = document.getElementById('ev-note');
if (t) { t.value = ''; }
if (lo) { lo.value = ''; }
if (no) { no.value = ''; }
return;
}
/* 插入不成功 = "没刷新却也没看到" → 宁可刷新一次,别假装成功 */
form.setAttribute('data-native-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
return;
}
if (d && d.msg) { sayErr(d.msg); return; }
/* 拿不到 JSON:会话过期 / 500 → 退回原生提交,让服务端说话 */
form.setAttribute('data-native-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
})
.catch(function () {
if (sub) { sub.disabled = false; }
form.setAttribute('data-native-submit', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
});
});
})();
/* 「全天」勾上藏掉时刻那一段的逻辑在 _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>