Files

302 lines
20 KiB
PHP
Raw Permalink Normal View History

<?php
/**
* 日历视图区(月 / 周 / 日)—— 从 templates/calendar/index.php 抽出来的独立片段。
*
* 🔴 工单 #37:新建 / 修改日程后要"不刷新页面、就地更新"。做法是**服务端渲染同一份
* 片段 → 前端整块替换**,而不是让 JS 手写复刻渲染逻辑。
* 这就必须把视图区从页面模板里抽出来,让整页路径与局部重绘走同一份代码 ——
* 只有一处渲染,"插进去的样子"与"刷新一次的样子"才不可能不一致。
*
* 抽出的方式:原文**一字未改**地搬过来,只把外层缩进调平。
* 所以这里用的变量与原来完全一样,由调用方给全:
*
* @var string $view month / week / day
* @var array $cols 周/日视图的列(day_columns 产出)
* @var array $tgrid build_time_grid 产出
* @var array $weeks build_month_grid 产出
* @var bool $canNew 能不能新建
* @var float $nowTop 当前时间红线位置(%,服务端算好)
*
* ⚠️ 调用方必须**在 require 之前**把上面这些变量准备好;本片段自己不做任何兜底
* —— 兜底会让"少给了一个变量"变成静默画错,而不是当场报错。
*/
?>
<?php /* 🔴 工单 #37:这个外层容器是**局部重绘的落点**。
JS 拿到服务端渲染的新片段后,只替换 `[data-cal-view]` 里面的内容
(换的是 .card 那一层,见 assets/js/cal_sync.js 的 swap())。
⚠️ 容器本身**不能**被替换掉:它上面挂着"当前视图"这个上下文,
替换容器会让 CSS 上依赖 .cal-main > * 的选择器重新算一遍(视觉会跳一下)。 */ ?>
<div class="cal-view" data-cal-view data-cal-view-kind="<?= e((string)$view) ?>" data-cal-ym="<?= e((string)($ym ?? '')) ?>" data-cal-anchor="<?= e((string)($anchor ?? '')) ?>">
<?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; }
?>
<?php
/* 🔴 v88(工单 #35):跨天日程的分段。
`seg` 由 `build_time_grid` 给出:
start = 起始段(从开始时刻到当日 24:00)
mid = 中间整天
end = 结束段(00:00 到结束时刻)
only / 空 = 单天日程(原样)
视觉上要让三段"连成一条":把段与段之间的圆角和内侧边框去掉,
只保留最外两端是圆的。具体在 CSS 的 `.tg-block.is-seg-*` 里。 */
$evSeg = (string)($ev['seg'] ?? '');
$evCont = !empty($ev['cont']);
$segCls = '';
if ($evSeg === 'start') { $segCls = ' is-seg-start'; }
elseif ($evSeg === 'mid') { $segCls = ' is-seg-mid'; }
elseif ($evSeg === 'end') { $segCls = ' is-seg-end'; }
/* 分隔线:起始段的下边不画(要跟下一段接上),
中间段上下都不画,结束段上边不画 —— 由 CSS 处理。 */
?>
<a class="tg-block<?= $segCls ?>" data-cal="<?= (int)$ev['cal'] ?>"
data-s="<?= (int)$evS ?>"<?= $evE !== null ? ' data-e="' . (int)$evE . '"' : '' ?>
<?php if ($evSeg !== '') : ?>data-seg="<?= e($evSeg) ?>"<?php endif; ?>
data-fb-entry='<?= fb_entry_json($ev, $evDet, $evEdit) ?>'
<?php
/* 🔴 v88:跨天段用**贴满整列**的定位(left:0 / width:100%),
和单天块的 `calc(2px + N%)` / `calc(N% - 4px)` 不是一套。
单天块要留 2px 好跟列边框区分;跨天段要跟上一列接上,
留缝就"断成三段"了(实测 10.9px 空隙)。
⚠️ 用 CSS 类而不是内联值区分:内联 calc 里做条件太绕,
而且两边混着写以后必然有人改错一边。 */
$evSegStyle = ($evSeg !== '')
? 'left: 0; width: 100%;'
: 'left: calc(2px + ' . (float)($ev['left'] ?? 0) . '%); '
. 'width: calc(' . (float)($ev['width'] ?? 100) . '% - 4px);';
?>
style="background: <?= e((string)$ev['color']) ?>;
top: <?= (float)$ev['top'] ?>%; height: <?= (float)$ev['height'] ?>%;
<?= $evSegStyle ?>"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?><?= $evCont ? '(接前一日)' : '' ?>">
<?php if (!$evCont && (string)($ev['time'] ?? '') !== '') : ?>
<b><?= e((string)$ev['time']) ?></b>
<?php endif; ?>
<?php if ($evCont) : ?><span class="tg-cont">↳</span><?php endif; ?><?= e((string)$ev['title']) ?>
</a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php else: ?>
<?php
/* 🔴 v70:把「每格全部条目」一次性送到前端(点 "+N 条" 时浮层直接用,
不再发请求)。**只送当月格子里的日期**,量很小(一个月最多几百条)。
⚠️ 用 `<script type="application/json">` 而不是把 JSON 塞进 data-*:
data-* 属性里几千条会被 HTML 转义得又长又容易截断;
JSON 里若出现 `</script>` 也会截断 —— 所以下面统一走 json_encode 的
JSON_HEX_TAG(把 < > 转成 \u003C \u003E,永远不会提前闭合)。
⚠️ 这是**渲染层**的便利数据,字段与 `data-fb-entry` 同一套(fb_entry_json),
所以浮层能沿用全部既有逻辑(详情/编辑/删除/来源判断)。 */
$dayData = [];
foreach (($weeks ?? []) as $wk) {
foreach ($wk as $cell) {
if (!$cell['events']) {
continue;
}
$items = [];
foreach ($cell['events'] as $ev) {
$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]); }
$items[] = ['_rank' => \App\Calendar::displayRank($ev),
'_time' => (string)($ev['time'] ?? '')]
+ json_decode(
fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit),
true
);
}
$dayData[(string)$cell['date']] = $items;
}
}
?>
<script type="application/json" id="calDayData"><?= json_encode(
$dayData,
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP
) ?></script>
<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
/* 🔴 v70(用户 2026-10-20 第 2 条):「日历导入进去了,但是不显示」。
真根因不在这里的 3 条,而在**排序 + "+N 条"点不开**的组合:
每格只画 3 条,而旧排序把"全天"(时间空串)排最前 ⇒ 农历/节气/
节假日天天占满前三,用户自己导入的日程全被挤进 "+N 条",
而那个 "+N 条" 只是个 <span>,点不开 = 完全看不到。
两处都已修:① `Calendar::displayRank()` 让派生条目排最后;
② 下面把 "+N 条" 换成**真按钮**,点开当天的全部条目浮层。
⚠️ 保留 3 条不改成"全画":格子是固定高度(min-height:104px),
15 条硬塞会把月网格撑得不像月历;超出部分交给浮层看。 */
$cellEvents = $cell['events'];
$shown = 3;
$hiddenCnt = count($cellEvents) - $shown;
?>
<?php $n = 0; foreach ($cellEvents as $ev): $n++; ?>
<?php if ($n <= $shown): ?>
<?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 ($hiddenCnt > 0): ?>
<?php /* 点开 = 当天全部条目(含被折叠的),一律走全站的条目详情浮层;
每条都带 data-fb-entry,所以在浮层里还能继续点进详情/编辑/删除。
⚠️ 用一个 `<a href>` 兜住无 JS 的情况:直接跳那天的日视图
(日视图按时间网格铺开,不会再截断)。 */ ?>
<button type="button" class="cal-more"
data-cal-day="<?= e((string)$cell['date']) ?>"
aria-label="看这一天全部 <?= count($cellEvents) ?> 条">+<?= $hiddenCnt ?> 条</button>
<?php /* 浮层用的全部条目数据(服务端渲染好,点开时不需要再请求)。
放在格子外面(display:none 的 script 里)—— 塞进格子会撑高月网格。 */ ?>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>