Files
FamilyBook/templates/calendar/index.php
T
laoyang d83c94e127 v37 第 37 批:日期控件换成「年/月/日 + 时/分」五个原生下拉 + 五处界面重做
用户原话(6 条,一次给全):
  ①「观影详情页面。观影图片下面有一个块…要加照片请点上方「编辑」…这个块不要,
     直接把这个块删除。」
  ②「编辑页面的时间…选日期的这个控件非常非常不好用。就是如果我把年和月那个地方
     拉下来,我选完年和月之后,它并不会回到让我选日的那个界面,它还是不动的…
     要么换一个控件,要么在那个地方加一个确定按钮…现在开始和结束时间分在了两行…
     能放得下的话就应该放一排…不要让它默认显示在两行上…"散场时间可不填。"这句话去掉。」
  ③「首页上今天的日程…分了几列,列之间的空隙非常非常的大…日程里面的那两个色块
     就是紧挨着的。所以…应该让它们紧挨着。」
  ④「现在怎么部署的时间这么长?…你修改了哪些页面,你就只测试哪些页面就可以了。」
  ⑤「日程模块的新建日程…"加上"和"取消"两个按钮在右上角,离得特别远,根本看不到…
     每个输入框前面的标题都太小了…都应该加粗、变大。」
  ⑥「1.新建任务的截止时间,也和观影一致。2.新建任务时竟然没有添加附件…
     3.新建任务也弹出一个框,新建完之后刷新看板页面。4.观影新增也弹框输入所有信息。」

② 日期控件:**真根因是"这个控件给不了"**
  `<input type="date">` 的日历弹层由**浏览器自己画**,我们碰不到 DOM、也控制不了流程;
  Chrome 在"点顶部 `2026年 10月` 切到年月视图"之后,要靠用户**再点一次日期或按 Esc**
  才回得到日期视图,各家还不一致 ⇒ 用户遇到的"选完年月就卡住、选不了日"。
  修:`_datetime.php` 重写成 **年 / 月 / 日 三个原生 <select> + 时 / 分**,
  选完即生效、无需"确定";秒默认去掉(用户"只记录到时和分")。
  ⚠️ 提交契约**一字未改**:`*_date` 仍是真字段(改 `type=hidden`)+ `*_hour/_minute`。
  ⚠️ `required` 落在隐藏控件上会让 Chrome 报 "not focusable"(点保存什么都不发生)
     ⇒ 改用 `data-dt-required` 声明、JS 自查(12 个调用点全部受影响)。
  起止两端:`.dt-range.dt-cols` + `flex-wrap` → **宽屏一行、窄屏自动换行**
  (不用 JS 量宽度:字体/动画/缩放时量不准,flex-wrap 是浏览器布局的一部分)。
  🔴 顺带挖出真 bug:旅程「修正」表单的 `.dt-range` 只有 **22px 宽**
     —— 它长在 `.node-ops`(一排小按钮的容器)里,展开的内容只有"修正"两个字那么宽。
     修:`.rev-panel{position:absolute}` 纯 CSS 浮出来(不动 DOM,避免牵动节点列表排版)。

③ 首页冲突色块"空隙特别大":与日历页逐字对比出来的
  日历页 `left: calc(2px + L%)` / `width: calc(W% - 4px)` → 只留 4px、紧挨;
  首页却写死 `calc(46px + L%)` / `calc(W% - 50px)` —— 每块左右各扣 23/25px,
  两列时那 46px **没法均分**、全挤进了中间的缝(实测 ≈50px)。
  修:`left: calc(46px + L% + 2px)` / `width: calc(W% - 4px)` → 缝恒为 4px。

⑤ 日程弹框"按钮看不到":**两个**根因叠加
  ① `calendar/index.php` 里**多写了一个 `</div>`** —— `.cal-modal-panel` 被提前闭合,
     底栏 `<div class="cal-modal-foot">` 成了 `.cal-modal` 的直接子元素、被挤到面板外;
  ② `.cal-modal-panel` 是 flex 列,但**它下面的 `<form>` 不是** →
     `.cal-modal-body{flex:1 1 auto}` 的上下文只到 form 为止,body 压不下去也不滚。
  另有 `.cal-modal-panel` 的 max-height 没算上 `.cal-modal` 自己的 padding(差 4px)。
  三处都修;标签只对**弹框里**的 `.field > label` 加粗放大(14px/600/深色)。

⑥ 任务新建:内联卡片 → 弹框;**新增附件**(`attach[]`,服务端零改动能收);
  `back=tasks` → 新建完回**看板**刷新。观影新增:列表页内嵌同一份 form.php 弹框。

① 详情页删掉那句提示块。④ 流水线改**增量**:`run_suite_if <标签>` + 从 git 改动
  推模块;**一碰共用件就退回全量**(app.js/app.css/layout/index.php/_datetime.php…),
  认不出来也全量(保守:少跑一段发坏东西的代价远大于多跑十分钟)。

🔴🔴 本轮最值得记的教训:**DOM 嵌套错误比语法错误难查得多**
  在 movies/form.php 与 calendar/index.php 各写坏一次嵌套(多/少 `</div>`、多 `</form>`),
  两处**都通过 php -l、页面也打得开**,症状却是"按钮跑到面板外"与
  "编辑保存静默失效"(保存键落到 form 外面)。⇒ 模板里 `<?php if ?>` 与 `</div>`
  必须**按分支配对**,改完用真机渲染 dump 一次 div 深度;光靠 php -l 发现不了。

验证(全部真机 + 反向验证):
  · _fb_b34_dtrange.py **61/0**(四个页面:年/月/日/时/分 都可见可点 +
    选完三个下拉**立刻**写回 *_date + 改月重建"日"列 + 起止默认同一行 + 无 JS 报错);
  · _fb_b34_dtrange_falsify **7/0**(三处注入各自必须变红);
  · _fb_b37_ui.py **27/0**(详情页无提示块 / 首页冲突缝 ≤8px / 弹框按钮在面板内且在
    视口内 / 标签加粗 / 任务弹框+附件+回看板 / 观影列表页弹框);
  · _fb_b37_falsify **5/0**;
  · _fb_b35_movie_media.py **16/0**、_fb_geom_interact **109/0**、e2e **1503/0**、
    b30_ui2 16/0(含首页冲突分列)、_b30_import 26/0、b18/b21/b23/…全绿、
    DAV 三套全绿、部署静态断言空跑通过;
  · 线上 app.js / app.css / movies/form.php / calendar/index.php 的 SHA256 与本地一致。
  · 顺带修掉 _b30_import 的一个**时序竞争**:它用外部上游(联想词服务)的二次探测
    当对照,浏览器取词在前、探测在后,上游抖一下会误报成"回归"(已改成两次确认)。
2026-10-10 00:26:42 +08:00

874 lines
49 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 /* 🔴 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">
<div class="field full">
<label for="ce-cal">放在哪本</label>
<?php /* v24 第 21 批 第 7 条:「选择日历本,带上颜色」。
⚠️ 原生 <select> 的 <option> **画不出色块**(浏览器不让你在
option 里放元素,background 也基本不生效)→ 所以做成
「色块 + 下拉」并排:左边一块跟着当前选中的本子变色,
右边还是那个原生下拉(保持零依赖、手机上也能滚)。
切换时由下面那段 JS 同步色块颜色。 */
/* 🔴 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="cal-book-row">
<span class="cal-book-dot" id="ce-cal-dot"
style="background: <?= e((string)($firstCal['color'] ?? '#4A7C82')) ?>;"></span>
<select class="select" id="ce-cal" name="calendar_id">
<?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 说明,是这个取舍里最不费解的一种。
观影 / 旅行**不禁用**:它们可写(加一条 = 建一条观影/旅程)。 */ ?>
<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>
</div>
<?php if (!$hasPersonal): ?>
<span class="tip" style="display:block;margin-top:6px">
⚠️ 你还没有**自己的日程本**,上面选的是一本"派生本"
(加进去会变成观影 / 旅程记录)。去
<a href="<?= e(url('calendar/calendars')) ?>">日历本</a> 建一本吧。
</span>
<?php endif; ?>
</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><?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): ?>
<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 /* 🔴 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): ?>
<a class="tg-block" data-cal="<?= (int)$ev['cal'] ?>"
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): ?>
<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'); /* 头部 ✕ */
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('.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'; }
}
/**
* 打开新建日程的**弹框**(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();
}
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;
}
/** 周/日视图:按"列高 = 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 || ''));
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);
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>