跨天日程:周/日视图按列分段画成一条,不再拆成碎片 laoyang/FamilyBook#35

用户 2026-10-30:「网页端,跨天日程显示还是有问题…如果一个日程跨了三天,
应该在日历本上怎么显示呢?在周视图和日视图,应该怎么显示?现在显示的有问题。」

## 修复前是什么样(实测,不是推断)

一个「11/10 20:00 → 11/12 02:00」的跨三天日程,服务端 month() 展开成三条
(首日带 time、中间天两者皆空、末日带 end_time),喂给 build_time_grid 后:

    全天行:11/11、11/12 各一条     ← 中间两天变成"全天日程"
    时间网格:11/10 一个小块,高度仅 2.08%(≈30 分钟)

三个问题叠加:
  · 开始日的结束时刻不在同一天 ⇒ 判成"没有结束" ⇒ 退化成 30 分钟
  · 中间/末日既无 time 又不是全天 ⇒ 被"按全天处理"的兜底塞进全天行
  · 三条互不相干,用户看不出"这是同一个日程"

## 方案(用户已确认)

  1. 周视图:跨列连续长条 —— 毎列各画一段,视觉连成一条
  2. 日视图:铺满当天并标延续
  3. 中间天归时间网格,不进全天行

## 改了什么

**app/Calendar.php** —— 服务端补两个字段
  · `span_days`:整条日程跨了几天(0 = 当天来回)
  · `seg`:start / mid / end / only
  同时修掉一个真 bug:原来 `$first` 是"窗口内遍历的第一个",
  日视图只查一天时,中间天会变成 `$first` 被当开始日处理。
  ⇒ 一律用**真实日期**跟 `$base`/`$base+span` 比较,不看遍历顺序。

**index.php::build_time_grid** —— 核心重构
  · 先按 `kind:ref_id:occ` 归堆,把同一日程的逐天条目合成一条
    (带 occ 是因为重复日程不同次发生的 ref_id 相同;带 kind 是因为
     观影/旅程的 ref_id 是各自表里的 id,`custom:5` 和 `movie:5` 是两回事)
  · 跨度以服务端 `span_days` 为准,不再用"窗口内铺了几列"推断
    (日视图下那个推断必然不成立)
  · 跨天条目拆成每列一段,按 `seg` 决定纵坐标:
    start = 开始时刻→24:00,mid = 整列,end = 0→结束时刻
  · 跨天条目**不参与 conflictLanes** —— 它必然占满整列,
    拿去分列会把同一天所有日程都挤窄

**templates/calendar/index.php** —— 分段渲染
  · `is-seg-start/mid/end` 类;延续段前面加 `↳` 标记,不重复显示时间
  · 跨天段用 `left:0; width:100%`,单天块保持原来的 `calc(2px + N%)`

**assets/css/app.css** —— 让三段看着连成一条
  · start 下圆角归零、下边框透明;mid 上下都平;end 上圆角归零、上边框透明
  · 段与段之间原本有 1px 白边 + 左右内缩,实测缝隙 10.9px,看着像断成三段
    ⇒ 跨天段贴满整列后降到 1.0px(只剩列边框)

## 为什么在列内画段,不做跨列绝对定位层

`.tg-body` 左边有 56px 固定 gutter,窄屏还会横向滚动
(`.cal-tg-card{overflow:auto}` + 每列 min-width 92px),`.tg-col` 又是
`overflow:hidden`。跨列层要自己算 gutter 偏移、跟着横向滚动跑、还不能被裁 ——
每个都是新坑。在列内画段天然对齐,代价只是段间有 1px 列边框缝,用 CSS 糊掉。

## 验证

**逻辑层(PHP,用真实 build_time_grid)**
  · 周视图跨三天:3 段(start/mid/end),全天行为空 ✓
  · 日视图三个位置(起始/中间/结束天)各自正确 ✓
  · 回归:单天 + 真全天仍正常;冲突分列仍生效;跨天与普通日程共存正常 ✓
  · 反向验证:用修复前的代码跑同一判据**正确转红**
    (旧版 timed=1 all_day=2,高度只有 2.08%),证明判据非恒真 ✓

**视觉层(Playwright + 真实 CSS + include 真实模板)32 项全过**
  · 横向连续:start→mid 缝隙 1.0px,mid→end 1.0px(判据 ≤2.5px)✓
  · 纵向:start 底边贴列底 0px;mid 占满整列(顶 0 / 底 0);
    end 顶边贴列顶 0px 且只占开头一小段 ✓
  · 样式:start 下圆角 0px + 下边框透明;mid 上下圆角 0;end 上圆角 0 +
    上边框透明 ✓
  · 无裁切:三段都落在列内 ✓
  · 回归:真全天仍在全天行;跨天不再出现在全天行;单天日程正常 ✓
  · 窄屏 375px:三段仍可见(w=91)✓

**反向验证 4/4 全部抓到**
  故意做坏四个变体(段留单天内缩 / 保留圆角 / 中间段只画 30% /
  下边框不透明),判据全部转红 —— 说明"32 项全过"不是因为判据恒真。

## 已知取舍

跨天段占满整列,同一天的普通日程与它在视觉上会重叠。
这是日历的固有取舍(Google 日历同样如此),用户确认的方案里也接受。
This commit is contained in:
2026-10-30 23:43:10 +08:00
parent 73c84c1910
commit 01d5bc262f
4 changed files with 362 additions and 33 deletions
+29 -5
View File
@@ -1526,9 +1526,29 @@ final class Calendar
$vetm = $vd['ends_at'] ? substr((string)$vd['ends_at'], 11, 5) : '';
// 跨天日程:从这次发生的当天起,按事件本身的长度铺开
$span = (int)floor((strtotime($bed) - strtotime($bsd)) / 86400);
$first = true;
foreach (self::eachDay($base, date('Y-m-d', strtotime($base . ' +' . $span . ' day')), $from, $to) as $d2) {
$isLast = ($d2 === date('Y-m-d', strtotime($base . ' +' . $span . ' day')));
/* 🔴 v88(工单 #35):`$first` 原来是"窗口内遍历的第一个",**这是错的**。
日视图只查一天时,如果那天正好是跨天日程的**中间天**,
它就成了 `$first` ⇒ 被当作"开始日"处理(但 `time` 是空的),
于是落到全天行;同理 `$isLast` 也会把中间天当结束日。
⇒ 一律用**真实日期**跟 `$base`/`$base+span` 比较,不看遍历顺序。
另外把「这条跨度」本身也带出去(`span_days` / `seg`),
让端上/网页端不必再靠"同一次发生铺了几条"去猜 ——
那个推断在窗口被裁剪时(日视图)必然不成立。 */
$baseEnd = date('Y-m-d', strtotime($base . ' +' . $span . ' day'));
foreach (self::eachDay($base, $baseEnd, $from, $to) as $d2) {
$isFirst = ($d2 === $base);
$isLast = ($d2 === $baseEnd);
// 段形态:整天(span=0)就是 only
if ($span === 0) {
$segKind = 'only';
} elseif ($isFirst) {
$segKind = 'start';
} elseif ($isLast) {
$segKind = 'end';
} else {
$segKind = 'mid';
}
$out[] = [
'cal' => (int)$e['calendar_id'],
'color' => (string)($c['color'] ?? '#4A7C82'),
@@ -1536,12 +1556,17 @@ final class Calendar
'title' => (string)$vd['title'],
'date' => $d2,
// 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻
'time' => ($first && empty($vd['all_day'])) ? $vtm : '',
'time' => ($isFirst && empty($vd['all_day'])) ? $vtm : '',
// 结束时刻同理:只落在**最后一天**上(那才是它真正结束的那天)
'end_time'=> ($isLast && $vetm !== '' && empty($vd['all_day'])) ? $vetm : '',
'all_day' => !empty($vd['all_day']),
'kind' => 'custom',
'ref_id' => (int)$e['id'],
/* 跨度信息(v88):网页端据此把一条画成多列分段;
日视图下窗口只给一天也不会判错。
`span_days` = 跨了几天(0 表示当天来回)。 */
'span_days' => $span,
'seg' => $segKind,
// 🔴 v43(用户第 6 条):条目详情浮层要显示"更多信息",
// 所以地点/备注/状态也要带出去(原来模板只用得到 title/time)。
'location' => (string)($vd['location'] ?? ''),
@@ -1553,7 +1578,6 @@ final class Calendar
'_can_write' => $canW,
'href' => url('calendar/event', ['id' => (int)$e['id'], 'd' => $day]),
];
$first = false;
}
}
}
+55
View File
@@ -3529,6 +3529,61 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
.tg-block:hover { text-decoration: none; filter: brightness(1.1); z-index: 2; }
.tg-block b { font-weight: 600; margin-right: 3px; }
/* ============================================================
跨天日程的分段(v88 / 工单 #35)
============================================================
用户:「如果一个日程跨了三天,应该在日历本上怎么显示呢?在周视图和日视图,
应该怎么显示?现在显示的有问题。」
做法:一个跨天日程在**每一列各画一段**(`build_time_grid` 算好每段的
top/height),视觉上要让它们看起来是**一条**,而不是三个互不相干的块。
为什么不做跨列绝对定位层:`.tg-body` 左边有 56px 固定 gutter,
窄屏还会横向滚动(`.cal-tg-card{overflow:auto}` + 每列 min-width 92px),
`.tg-col` 又是 `overflow:hidden`。跨列层要自己算偏移、跟滚动跑、还不能被裁。
在列内画段则天然对齐,代价只是段间有 1px 列边框缝 —— 用下面的样式糊上。
三段的关系:
· start(起始段):上圆下平 —— 下方要接中间段
· mid (中间段):上下都平 —— 两头都接
· end (结束段):上平下圆 —— 上方接中间段
⚠️ 内侧不画 border:`.tg-block` 原本有 1px 白边,段与段之间会多出两条白线,
看着像断开了。这里按段位取消对应的那条边。
⚠️ 左右内缩量要比单天块**小**(单天是 left:2px / width:-4px):
跨天段要尽量贴满整列,缝隙才窄,连起来才像一条。模板已给 1%/98%。 */
.tg-block.is-seg-start {
border-top-left-radius: 4px; border-top-right-radius: 4px;
border-bottom-left-radius: 0; border-bottom-right-radius: 0;
border-bottom-color: transparent; /* 下边不画,与中间段连上 */
padding-bottom: 0;
}
.tg-block.is-seg-mid {
border-radius: 0;
border-top-color: transparent;
border-bottom-color: transparent;
padding-top: 1px; padding-bottom: 0;
}
.tg-block.is-seg-end {
border-top-left-radius: 0; border-top-right-radius: 0;
border-bottom-left-radius: 4px; border-bottom-right-radius: 4px;
border-top-color: transparent; /* 上边不画,与中间段连上 */
padding-top: 1px;
}
/* 延续标记:这段不是从今天开始的,用一个小箭头代替开始时刻 */
.tg-cont {
display: inline-block; margin-right: 2px;
opacity: .75; font-size: 10px; line-height: 1;
}
/* 跨天段占满整列宽度,内边距跟着收一点,字才不会被挤 */
.tg-block[data-seg] {
padding-left: 4px; padding-right: 4px;
/* 🔴 v88:跨天段用 left:0 / width:100%(模板按段区分),
必须配 box-sizing:border-box,否则 padding 会把宽度撑出列外。
`.tg-block` 已有 box-sizing:border-box,这里显式再写一次是为了防止
以后有人给单天块改 box-sizing 时顺手把这条也改掉。 */
box-sizing: border-box;
}
/* 天视图只有一列,把列拉宽好读;周视图 7 列时列宽由 flex 平分 */
@media (max-width: 720px) {
/* 窄屏留不住 56px 的刻度沟槽 + 7 列,改成整体可横向滚动(.cal-tg-card 已 overflow:auto),
+232 -15
View File
@@ -523,33 +523,130 @@ function build_time_grid(array $events, array $cols): array
$allDay = array_fill(0, count($cols), []);
$timed = array_fill(0, count($cols), []);
/* ==================== 跨天日程:先按"同一个日程"归堆(v88 / 工单 #35)====================
用户 2026-10-30:
> 网页端,跨天日程显示还是有问题…如果一个日程跨了三天,应该在日历本上怎么显示呢?
> 在周视图和日视图,应该怎么显示?现在显示的有问题。
为什么会坏:`Calendar::month()` 把跨天日程**逐天铺开成多条**
(第一天带 `time`、中间天两者皆空、末天带 `end_time`),
而下面原来的逻辑只按"单天"处理 ⇒
· 开始日:结束时刻不在同一天 ⇒ 判成"没有结束" ⇒ 退化成 30 分钟的小色块;
· 中间/末天:既没有 `time` 又不是全天 ⇒ 被那条"按全天处理"的兜底塞进**全天行**。
于是"跨三天的日程"在界面上变成「一个小块 + 两个全天条目」,三者还互不相干。
⇒ 现在先归堆,把同一个日程的逐天条目**合成一条**,并算清它跨哪几列。
⚠️ 归堆键必须带 `occ`:重复日程的不同次发生 `ref_id` 相同,
不带 `occ` 会把 11/10 和 12/01 两次发生揉成一条(那会画出横跨三周的怪东西)。
⚠️ 还要带 `kind`:观影/旅程/任务派生的条目 `ref_id` 是**各自表**里的 id,
`custom:5` 和 `movie:5` 是两回事。 */
$groups = [];
$order = [];
foreach ($events as $e) {
$ci = $dayIndex[(string)$e['date']] ?? null;
if ($ci === null) {
continue;
}
if (!empty($e['all_day'])) {
$allDay[$ci][] = $e;
$key = (string)($e['kind'] ?? 'custom') . ':' . (int)($e['ref_id'] ?? 0)
. ':' . (string)($e['occ'] ?? '');
if (!isset($groups[$key])) {
$groups[$key] = [];
$order[] = $key;
}
$groups[$key][] = ['ev' => $e, 'ci' => $ci];
}
foreach ($order as $key) {
$parts = $groups[$key];
// 列序号升序:跨天日程铺出来的顺序天然如此,但别依赖输入顺序
usort($parts, static function (array $a, array $b): int {
return $a['ci'] <=> $b['ci'];
});
$first = $parts[0]['ev'];
$last = $parts[count($parts) - 1]['ev'];
/* `$span` 是"窗口内铺了几列",**不能**直接当跨度用 ——
日视图只查一天时,跨天日程的中间天只有 1 条,`$span` 会是 1,
那样它就被误判成单天日程(这是 v88 修的第一个坑)。
⇒ 跨度一律以服务端的 `span_days` 为准;老数据没有该字段时才退回 count()。 */
$spanDays = isset($first['span_days']) ? (int)$first['span_days'] : (count($parts) - 1);
$segFirst = (string)($first['seg'] ?? '');
// 整条在窗口内只露出一天时,`_cols` 只有 1 项,但语义上它可能仍是"中间段"
$isMulti = ($spanDays > 0);
$span = count($parts);
/* 真正的全天日程:整条都在全天行,不进时间网格。
⚠️ `all_day` 为真时 `month()` **根本不铺跨天**(每条都带 all_day),
所以这里 `$span` 自然会是它实际占的天数,不需要特殊处理。 */
if (!empty($first['all_day'])) {
foreach ($parts as $p) {
$allDay[$p['ci']][] = $p['ev'];
}
continue;
}
$t = (string)($e['time'] ?? '');
// 有具体时刻才进时间格;没有时刻又不是全天的(如任务全天),按全天处理
if ($t === '' || !preg_match('/^(\d{2}):(\d{2})/', $t, $m)) {
$allDay[$ci][] = $e;
/* 时间网格:开始日有 `time`,末日的 `end_time` 是它真正结束的时刻。
🔴 v88(工单 #35):判"要不要进时间网格"**不能只看 `time` 是否为空**。
跨天日程在窗口里只露出"中间天/结束天"时(日视图),那条的 `time` 必然是空的,
旧逻辑就把它当全天了 —— 用户看到的是"跨天日程在日视图里变成全天条目"。
⇒ 只要服务端说它是跨天(`span_days > 0` 且 `seg` 不是 only),
就算没有 `time` 也进时间网格;起始时间从服务端补不出来时按 0 点算。 */
$t = (string)($first['time'] ?? '');
$hasTime = ($t !== '' && preg_match('/^(\d{2}):(\d{2})/', $t, $m));
$isMultiSeg = ($spanDays > 0 && $segFirst !== '' && $segFirst !== 'only');
if (!$hasTime) {
if ($isMultiSeg) {
/* 跨天的延续段:没有开始时刻是正常的(它不是这一天开始的)。
起始分钟按 0 算 —— 由下面的分段逻辑按 `seg` 决定真实纵坐标,
这里的 `_start` 只用于排序。 */
$m = [1 => '0', 2 => '0'];
} else {
// 单列、无时刻 ⇒ 沿用老规矩当全天(任务那种)
foreach ($parts as $p) {
$allDay[$p['ci']][] = $p['ev'];
}
continue;
}
}
$startMin = ((int)$m[1]) * 60 + (int)$m[2];
// 结束时刻:有就按真实时长;没有就先空着(下面还要看**下面那一条的起点**)。
$et = (string)($e['end_time'] ?? '');
$endMin = null;
$et = (string)($last['end_time'] ?? '');
if ($span > 1) {
/* 跨天:末日给了几点就画到几点;没给就画到当天 24:00
("跨天但没说什么时候结束"—— 至少画满当天,别缩成一小块)。 */
if ($et !== '' && preg_match('/^(\d{2}):(\d{2})/', $et, $m2)) {
$endMin = ((int)$m2[1]) * 60 + (int)$m2[2];
}
$endMin = ($endMin === null || $endMin <= 0) ? 1440 : $endMin;
} else {
// 单天:老逻辑(有结束就按真实时长,没有就看同列下一条 —— 下面再算)
if ($et !== '' && preg_match('/^(\d{2}):(\d{2})/', $et, $m2)) {
$endMin = ((int)$m2[1]) * 60 + (int)$m2[2];
if ($endMin <= $startMin) {
// 结束时刻不在开始之后(跨天日程的第二天、或用户填反了)→ 当没有结束
$endMin = null;
}
}
$timed[$ci][] = $e + ['_start' => $startMin, '_end' => $endMin];
}
/* ⚠️ `$first` 里带着**服务端给的 `seg`**(那是"这条在原日程里属于哪一段"),
但归堆后一条要代表整个跨度,不能留着首段的值 ——
我们另外用 `_segFirst` 表示"窗口内首段的形态"。
PHP 的 `+` 是"左边优先、不覆盖",所以必须先把它 unset 掉。 */
$entry = $first;
unset($entry['seg'], $entry['span_days']);
$timed[$parts[0]['ci']][] = $entry + [
'_start' => $startMin,
'_end' => $endMin,
/* 跨天信息:模板据此把一条画成多列的分段。
`_cols` = **窗口内**它占的列序号(升序),`_span` = 窗口内几列,
`_spanDays` = 整条真实跨度(服务端给的),`_segFirst` = 首段形态。 */
'_cols' => array_map(static function (array $p): int { return $p['ci']; }, $parts),
'_span' => $span,
'_spanDays' => $spanDays,
'_segFirst' => $segFirst,
];
}
/* ==================== 冲突错开(v30 第 4 条)====================
@@ -564,7 +661,41 @@ function build_time_grid(array $events, array $cols): array
没有下一条时才退到 30 分钟的最短可见块。
② 分列用的"结束分钟"必须**按列内顺序**算,所以必须先分列、再算高度。
这里用一个中间量 `_effEnd` 把"实际显示到几点"带回给模板。 */
/* ==================== 跨天日程:一条拆成多列分段(v88 / 工单 #35)====================
用户 2026-10-30 确认的方案:「跨列连续长条」——
一个日程跨三天,就在三列里各画一段,视觉上连成一条(同 Google/Apple 日历)。
⚠️ 为什么在**每列内画段**、而不是做一层跨列绝对定位:
`.tg-body` 左边有 56px 固定 gutter,周视图/窄屏下还会横向滚动
(`.cal-tg-card { overflow:auto }` + 每列 min-width 92px),
`.tg-col` 又是 `overflow:hidden`。
跨列层要自己算 gutter 偏移、跟着横向滚动跑、还不能被裁 —— 每个都是新坑。
在列内画段则天然对齐,代价只是段与段之间有 1px 列边框缝,
用 CSS 把内侧圆角/边框去掉就能看着连成一条。
⚠️ 跨天条目**不参与 conflictLanes**:它必然占满整列,
拿去分列会把同一天所有日程都挤窄(因为它和谁都"重叠")。
跨天条目自己占满宽度,单天条目照常分列 —— 两者在视觉上会重叠,
这是日历的固有取舍(Google 日历同样如此)。 */
$timedOut = array_fill(0, count($cols), []);
// 先把单天条目按列拆出来,跨天条目单独走
$singleDay = array_fill(0, count($cols), []);
$multiDay = [];
foreach ($timed as $ci => $list) {
foreach ($list as $e) {
/* 判据用**真实跨度**(`_spanDays`),不是窗口内列数:
日视图只给一天时 `_span` 恒为 1,会把跨天日程误当单天。 */
$isMultiEv = ((int)($e['_spanDays'] ?? 0) > 0) || (($e['_span'] ?? 1) > 1);
if ($isMultiEv) {
$multiDay[] = $e;
} else {
$singleDay[$ci][] = $e;
}
}
}
foreach ($singleDay as $ci => $list) {
if (!$list) {
continue;
}
@@ -585,7 +716,6 @@ function build_time_grid(array $events, array $cols): array
// ③ 逐条算"实际显示结束":优先同列下一条的起点
$out = [];
$laneLastEnd = []; // 每列当前已排到哪儿(分钟)
foreach ($sorted as $k => $e) {
[$left, $width] = $lanes[$k] ?? [0.0, 1.0];
$lane = $width > 0 ? (int)round($left / $width) : 0;
@@ -617,15 +747,102 @@ function build_time_grid(array $events, array $cols): array
// 要排序请在拼好 `$clean` 之后排。
$clean = [];
foreach ($sorted as $k => $e) {
unset($e['_start'], $e['_end']);
$clean[] = $e + ($out[$k] ?? ['top' => 0, 'height' => 0, 'left' => 0, 'width' => 100]);
unset($e['_start'], $e['_end'], $e['_cols'], $e['_span']);
$seg = $out[$k] ?? ['top' => 0, 'height' => 0, 'left' => 0, 'width' => 100];
$seg['seg'] = '';
$seg['cont'] = false; // 是否"从昨天延续过来"
$clean[] = $e + $seg;
}
usort($clean, static function (array $a, array $b): int {
return $a['top'] <=> $b['top'];
});
$timed[$ci] = $clean;
$timedOut[$ci] = $clean;
}
return ['all_day' => $allDay, 'timed' => $timed];
/* ---- 跨天条目:一条 → 每列一段 ---- */
foreach ($multiDay as $e) {
$colsOf = $e['_cols'];
$s = $e['_start'];
$en = $e['_end'] === null ? 1440 : $e['_end'];
$nCols = count($colsOf);
foreach ($colsOf as $k => $ci) {
/* 🔴 v88:段形态以**服务端的 `seg`** 为准(start/mid/end/only)。
不能只看"在窗口里是不是第一个/最后一个" ——
日视图只有一列,中间天既不是窗口内第一个跨天段、也说不清是首是尾。
同日视图会退化成"中间天被当开始日 ⇒ 从 20:00 才开始画"这类错误。 */
/* 段形态从**窗口内首段**(`_segFirst`)往后推:
`_segFirst` 是服务端给的 start/mid/end/only。
⚠️ 不能读 `$e['seg']` —— 那是 `$first` 残留的旧值(归堆时已 unset,
这里再读会拿到空,就退化成"按窗口位置猜",日视图必错)。 */
$segFirst2 = (string)($e['_segFirst'] ?? '');
if ($segFirst2 !== '' && $segFirst2 !== 'only') {
// 窗口内第 k 段:首段照 _segFirst,之后按 中间 → 结束 推
if ($k === 0) {
$segEv = $segFirst2;
} elseif ($segFirst2 === 'start') {
$segEv = ($k === $nCols - 1) ? 'end' : 'mid';
} elseif ($segFirst2 === 'mid') {
$segEv = ($k === $nCols - 1) ? 'end' : 'mid';
} else {
$segEv = 'end';
}
} else {
$segEv = ($nCols === 1) ? 'only'
: ($k === 0 ? 'start' : ($k === $nCols - 1 ? 'end' : 'mid'));
}
/* 每段的纵向范围:
· start:从开始时刻 → 当日 24:00
· mid :整列 00:00 → 24:00
· end :00:00 → 结束时刻
· only :同 start(当天来回,理论上走不到这里)*/
if ($segEv === 'start' || $segEv === 'only') {
$topMin = $s;
$botMin = 1440;
} elseif ($segEv === 'end') {
$topMin = 0;
$botMin = max(1, $en);
} else {
$topMin = 0;
$botMin = 1440;
}
$spanMin = max(15, $botMin - $topMin);
$segEvData = $e;
unset($segEvData['_start'], $segEvData['_end'], $segEvData['_cols'],
$segEvData['_span'], $segEvData['_spanDays'], $segEvData['_segFirst'],
$segEvData['seg'], $segEvData['span_days']);
/* `cont` 告诉模板"这一段不是这天开始的" ⇒ 标题前加个延续标记。 */
/* 🔴 v88:跨天段要**贴满整列**,段与段之间才看不出缝。
模板给的默认内缩是 `left: calc(2px + 1%)` / `width: calc(98% - 4px)`
(单天块要留缝,好和列边框区分),跨天段不能沿用 ——
实测左右各缩 5~6px + 列间 1px 边框 = 相邻段之间有 10.9px 的空隙,
看着就是"断成三段",不是一条。
⇒ left=0 / width=100,用 CSS 的 calc 抵消掉那 2px/-4px。
⚠️ 模板里写的是 `calc(2px + X%)` 与 `calc(X% - 4px)`,
所以这里传 0 与 100 得到的正是"贴满列":
left = 2px + 0% = 2px,width = 100% - 4px —— 仍留 2px 让边界可见,
但相邻两段合计只差 4px + 1px border ≈ 5px,视觉上已连续。 */
$timedOut[$ci][] = $segEvData + [
'top' => round($topMin / 1440 * 100, 4),
'height' => round($spanMin / 1440 * 100, 4),
'left' => 0.0,
'width' => 100.0,
'seg' => $segEv,
'cont' => ($segEv === 'mid' || $segEv === 'end'),
];
}
}
// 每列再按 top 排一次(跨天段可能和单天条目混在一起)
foreach ($timedOut as $ci => $list) {
if (count($list) > 1) {
usort($list, static function (array $a, array $b): int {
return $a['top'] <=> $b['top'];
});
$timedOut[$ci] = $list;
}
}
return ['all_day' => $allDay, 'timed' => $timedOut];
}
/**
+38 -5
View File
@@ -296,16 +296,49 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
$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'] ?>"
<?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'] ?>%;
left: calc(2px + <?= (float)($ev['left'] ?? 0) ?>%);
width: calc(<?= (float)($ev['width'] ?? 100) ?>% - 4px);"
<?= $evSegStyle ?>"
href="<?= e((string)$ev['href']) ?>"
title="<?= e((string)$ev['title']) ?>">
<b><?= e((string)$ev['time']) ?></b> <?= e((string)$ev['title']) ?>
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>