用户 2026-10-11 反馈六条,逐条定位真根因后修复: ① 纪念日「日期」多出一个控件 —— 只保留最新的那个 两层根因:(a)原生 `input[type=date]` 当初没包在 `.dt-native` 里, v42 那条"AntD 挂上后收起原生框"的选择器根本不命中;(b)更要命的是 `.dt-native` 基类写着 `display: contents` —— 该元素**没有盒子**, 给它 position/width 完全无效 ⇒ 必须把 display 显式改回 block 才收得起来。 ② 编辑纪念日把底层列表页弄没 —— 「修改」原来是整页跳 `days/edit` (套 layout = 一张只有弹框的空白页)。改为 `?embed=1` 只吐弹框那一块, 列表页 JS 取回来替换 `#anNewCard` 并打开 —— 与「新建」同一观感。 内嵌形态标 `$anModalOnly` 以免打 standalone 标记(关闭时不再跳页)。 ③ 拖图裁剪的层被弹框盖住 —— `.crop-overlay` 是 z-index:100 而 `.cal-modal` 是 200 ⇒ 裁剪层被整块盖住。提到 260(与 v41 的 .cn-modal 同一处理)。 ④ 行程弹框里"参与的人"弹不出选人框 —— person_picker.js 只绑定一次, 而弹框表单是 AJAX 片段 ⇒ 里面的 `.pp` 从未被 init。三件事一起: 暴露 `window.fbInitPickers()`(幂等)+ layout 暴露 `window.fbLoadPicker()` (脚本是按需加载的)+ 塞完片段后补调初始化。 ⑤ 日程新建排版/控件/文案 —— 改走观影式 `.mv-grid`(标签对齐 + 冒号); 起止补上 `$dtPlugin='antd'`(试点正式推广到日程);标签「时间范围」→「开始 / 结束」; 删三句提示(结束留空 / 多选提醒 / 全天)。 ⑥ 条目详情浮层(首页日程 / 首页近期任务 / 日程页条目)—— 点条目不再直接跳页,弹浮层显示详情;「详细信息」跳该来源详情页; 「编辑」就地换成该来源的编辑表单(四处 `?embed=1` 片段)。 新增 `templates/_entry_popup.php` + `assets/js/entry_popup.js` + `fb_entry_json()`(三个入口共用一个编码函数)。 验证:php -l 157 文件全过;div 深度 33 组合全配平;片段体检 20 片段无越界; 本地 e2e 1510/0;v43 真机 33/0;v43 反向验证 8/0;v38 36/0;v42 33/0; b31/b32 反向验证 8/0 与 5/0;部署断言本地空跑 RC=0;完整流水线 PUSH_RC=0。
920 lines
53 KiB
PHP
920 lines
53 KiB
PHP
<?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。
|
||
🔴 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;
|
||
$evMvStyle = true; // 观影式排版(用户第 5 条)
|
||
echo '<div class="mv-grid">';
|
||
require __DIR__ . '/_event_fields.php';
|
||
echo '</div>';
|
||
unset($evMvStyle);
|
||
?>
|
||
</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]); }
|
||
?>
|
||
<a class="tg-block" data-cal="<?= (int)$ev['cal'] ?>"
|
||
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) {
|
||
// ⚠️ 优先走全局接口:它会把原生字段与 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>
|