Files
FamilyBook/templates/home.php
T
laoyang 8c651b43eb v43 第 43 批:六条反馈(界面 5 + 条目详情浮层)
用户 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。
2026-10-12 00:24:20 +08:00

685 lines
41 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
/**
* 首页 —— 一块 dashboard。
*
* ────────────────────────────────────────────────────────────────
* v24 第 24 批 用户第 2、3 条(本次重排,用户原话):
*
* 第 2 条:「首页上的功能…一个就是上面的简要信息,这样去写。早上好,中午好,
* 或者晚上好,某某某今天是几月几日?星期几农历几月几日?如果有节气的
* 话,或者有重要的节日的话,在这里显示。比如说今天是这个国庆节啊,
* 今天是端午节,或者是今天是中秋节,或者是今天是芒种,今天是大寒,
* 今天是小寒,今天是什么纪念日,今天是谁的生日等等。显示在这里
* 紧接着它的下面显示今天一共有多少条日程,今天有几件代办正在进行。
* 把天气放在这个信息面板的最右侧,以图标的形式,彩色图标的形式去进行
* 展示。比如说晴天多云下雨,然后下面显示呃温度、风力等等,在最下面
* 再显示呃地址。就是比如说他选中的是他在个人设置里面设置的是青岛,
* 那么就显示山东青岛就是让它放在这个信息面板的最右侧,靠右放。」
*
* 第 3 条:「家里的镜框这里呃全部不要了…紧接着直接显示日程和任务日程和任务在
* 整个页面里面左右,并排放各占一半,日程就显示今天的日程,也就是
* 日视图那样形式,为了好看高度需要固定。这样的话,鼠标放上去之后,
* 向下可以滚动,暂时不要出现滚动条太丑了。为了节省上下滚动的这个空间,
* 我们可以把没有日程的那些时间掐头去尾给它去掉。…同样的和日视图一样,
* 需要在当前的时间或者是时刻那里显示一条红线。右侧显示任务模块里面的
* 数据显示当前正在进行当中的所有的任务,除了显示任务的标题之外,
* 还要显示他的状态。比如说正在进行中待确认等等。除了这之外,
* 还要显示他所处的看板的名称,就是他现在被放在哪一个看板里面,
* 就后最下面显示的是往年的今天这个模块。」
*
* ⇒ 所以现在首页自上而下只有三块:
* ① 信息面板(问候 + 日子标记 + 今日数字 | 最右:彩色天气图标 + 温度风力 + 地址)
* ② 日程 / 任务 **左右各半**(各自固定高度、内部滚动、不显滚动条)
* ③ 往年的今天
*
* ⚠️ 被删掉的(用户明确说"全部不要了"):
* · 「家里的近况」模块卡片墙(moduleCards)—— 那张一片一片的统计卡
* · 「未来七天」清单 —— 与左侧「今天日程」重复
* 两块都不再渲染。服务端仍照旧算 moduleCards(个人设置里那个开关还留着),
* 但首页不再用 —— 留着它是为了"以后要恢复"时不用动后端。
*
* ⚠️ 「家人卡片」「两个月内的生日」早先就已按用户要求撤掉,本批不变。
*/
// 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出
$today = date('Y-m-d');
// 首页要显示哪些模块、什么顺序 —— 用户在「个人设置 → 首页上的模块」里定(每人一份)
$homeMods = array_values(array_filter(
(array)($homeModules ?? []),
static fn($k) => \App\Module::exists((string)$k)
));
$todayMarks = $todayMarks ?? ['tags' => [], 'line' => ''];
?>
<?php /* 用户 2026-10-03 第 3 条:
① 标题下显示**个人 slogan**(原来撤掉了,现在要回来);
② 右侧那三块统计(与我有关 / 名册总数 / 亲属连线)**去掉**;
③ 「我的资料 / 我的家谱」保留,但从文字按钮换成**图标按钮**;
④ 「名册」按钮去掉 —— 它与顶部导航条里的「名册」重复。
⚠️ 图标用行内 SVG(不引外部字体/图片),与观影设置下拉同一套做法。 */ ?>
<section class="hero">
<img class="hero-logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
<div class="hero-main">
<h1><?= e(\App\Auth::homeTitle()) ?></h1>
<?php $heroSlogan = trim((string)($mySlogan ?? '')) !== ''
? (string)$mySlogan
: trim((string)($family['motto'] ?? '')); ?>
<?php if ($heroSlogan !== ''): ?>
<p class="hero-slogan"><?= e($heroSlogan) ?></p>
<?php endif; ?>
</div>
<?php /* ⚠️ 这两个链接必须带上真实的 person id:曾经 layout.php 里写
`$me = Auth::check()` 把模板里的 $me 覆盖成布尔值,链接就变成 id=0
("补充亲属关系"点进去是空白页)。所以这里显式用 $meId。 */ ?>
<div class="hero-actions">
<a class="icon-btn" href="<?= e(url('people/show', ['id' => (int)$meId])) ?>"
title="我的资料" aria-label="我的资料">
<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="10" cy="7" r="3.4"/><path d="M3.6 17c0-3.3 2.9-5.4 6.4-5.4s6.4 2.1 6.4 5.4"/>
</svg>
</a>
<a class="icon-btn" href="<?= e(url('kin')) ?>"
title="我的家谱" aria-label="我的家谱">
<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="7.4" y="2.2" width="5.2" height="4.2" rx="1"/>
<rect x="2.2" y="13.6" width="5.2" height="4.2" rx="1"/>
<rect x="12.6" y="13.6" width="5.2" height="4.2" rx="1"/>
<path d="M10 6.4v3.4M4.8 13.6v-1.9h10.4v1.9M4.8 11.7h10.4"/>
</svg>
</a>
</div>
</section>
<?php /* ==================== ① 信息面板(用户第 2 条)====================
左:问候 + 姓名 → 日期/星期/农历 → 日子标记(节气/节日/纪念日/生日)→ 今日数字
右:**彩色**天气图标 + 温度 + 风力 → 地址
⚠️ 天气靠右:用户原话「把天气放在这个信息面板的最右侧…靠右放」。
用 flex + margin-left:auto 把它顶到右端(窄屏会自动折到下一行,见 CSS)。 */ ?>
<section class="section">
<div class="card card-pad home-brief">
<div class="hb-left">
<?php /* ① 问候(早上好/中午好…)+ 我的称呼 —— 问候语里已经带了名字,
但用户明确要求「早上好,某某某」这样的形式,所以名字单独再强调一次。
(Home::greeting 本身会拼 "早上好,老杨,今天有 3 件事",
这里只用它的"时段"部分,尾巴换成面板里更该看的信息。) */ ?>
<?php
$hbPart = '你好';
$hbH = (int)date('G');
if ($hbH < 5) { $hbPart = '夜深了'; }
elseif ($hbH < 11) { $hbPart = '早上好'; }
elseif ($hbH < 13) { $hbPart = '中午好'; }
elseif ($hbH < 18) { $hbPart = '下午好'; }
else { $hbPart = '晚上好'; }
?>
<div class="hb-hello">
<b><?= e($hbPart) ?></b><?php if (trim((string)($meName ?? '')) !== ''): ?>,<?= e((string)$meName) ?><?php endif; ?>
</div>
<div class="hb-date">
<span class="hb-d"><?= e((string)($todayLabel ?? date('n 月 j 日'))) ?></span>
<span class="hb-w"><?= e((string)($weekday ?? '')) ?></span>
<?php if (($lunarShort ?? '') !== ''): ?>
<span class="hb-lunar" title="<?= e((string)($lunarLine ?? '')) ?>"><?= e((string)$lunarShort) ?></span>
<?php endif; ?>
</div>
<?php if (!empty($todayMarks['tags'])): ?>
<div class="hb-marks">
<?php foreach ($todayMarks['tags'] as $mk): ?>
<span class="hb-mark is-<?= e((string)$mk['kind']) ?>"><?= e((string)$mk['word']) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="hb-stats">
<a class="hbs" href="<?= e(url('calendar')) ?>">
<b><?= count($todayEvents ?? []) ?></b> 条日程(今天)
</a>
<?php /* 🔴🔴 v24 第 25 批 用户第 3 条修:「为什么显示 0 条待办呢?
任务看板明明有一个进行中的任务啊。」
根因:这句话原来读的是 `stat7['todos']` = `count(Task::byStatus(...,'todo'))`
—— 只数**待办**。而右边那块「手头在做」列的是 **doing(+ 已逾期的待办)**,
用户刚在看板上拖成「进行中」的那件任务于是**不计入**,数字就显示 0。
两个口径不同源,数字和列表当场对不上。
⇒ 现在这个数字与右侧列表**同源**:`doing + 已逾期的 todo`
(也就是 `count($tasks)`,与 index.php 传给本页的那个数组完全一致)。
文案也说清是什么:「件在做」,而不是含糊的「件待办」。
⚠️ 「已经动手」与「排队等着」是两件事(用户第 6 条要求说清区别):
首页要回答的是"我手头在做什么",所以主数字用「在做」的口径;
排队的那些(todo)另给一个小字,不必让人点进任务页才知道还有多少。 */ ?>
<a class="hbs" href="<?= e(url('tasks')) ?>"
title="近期任务(进行中 + 已逾期的待办)">
<b><?= count($tasks ?? []) ?></b> 件在做
</a>
<?php if ((int)($stat7['todos'] ?? 0) > 0): ?>
<a class="hbs" href="<?= e(url('tasks')) ?>" title="记下来了、还没动手的那些">
<b><?= (int)$stat7['todos'] ?></b> 件待办
</a>
<?php endif; ?>
<?php if ((int)($stat7['overdue'] ?? 0) > 0): ?>
<a class="hbs is-late" href="<?= e(url('tasks')) ?>">
<b><?= (int)$stat7['overdue'] ?></b> 件逾期
</a>
<?php endif; ?>
</div>
</div>
<?php if (!empty($weather)): ?>
<div class="hb-weather is-<?= e((string)($weather['tone'] ?? 'cloud')) ?>">
<span class="hbw-icon" aria-hidden="true"><?= e((string)($weather['icon'] ?? '')) ?></span>
<div class="hbw-main">
<div class="hbw-temp"><?= e((string)($weather['temp'] ?? '')) ?></div>
<div class="hbw-text"><?= e((string)($weather['text'] ?? '')) ?></div>
<?php if (($weather['wind'] ?? '') !== ''): ?>
<div class="hbw-wind">
<?= $weather['wdir'] !== '' ? e((string)$weather['wdir']) . ' ' : '' ?><?= e((string)$weather['wind']) ?>
</div>
<?php endif; ?>
</div>
<?php if (($weather['place'] ?? '') !== ''): ?>
<div class="hbw-place"><?= e((string)$weather['place']) ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</section>
<?php /* ==================== ② 日程 / 任务 左右各半(用户第 3 条)====================
⚠️ 高度**固定**(CSS 里给 .hd-pane-body 一个 max-height):
用户原话「为了好看高度需要固定。这样的话,鼠标放上去之后,向下可以滚动,
暂时不要出现滚动条太丑了」——
所以是 `overflow-y:auto` + 隐藏滚动条(`scrollbar-width:none` 等三套写法)。
⚠️ 日程掐头去尾:见下面 $hr 的算法 —— 只画"第一个日程前一小时"到"最后一个日程后一小时",
中间空档不裁(裁了会让人以为日程连在一起)。全天日程永远在顶上。 */ ?>
<?php
// ---- 日程时间轴:掐头去尾(用户第 3 条"没有日程的那些时间掐头去尾给它去掉")----
$allDay = [];
$timed = [];
foreach (($todayEvents ?? []) as $ev) {
if ((string)($ev['time'] ?? '') === '') { $allDay[] = $ev; }
else { $timed[] = $ev; }
}
$startH = 0; $endH = 24;
if ($timed) {
$mins = array_map(static function (array $e): int {
$p = explode(':', (string)$e['time']);
return ((int)($p[0] ?? 0)) * 60 + (int)($p[1] ?? 0);
}, $timed);
// 前后各留一小时:紧了会让人以为"到点就结束了",松了又浪费高度
$startH = max(0, intdiv(min($mins), 60) - 1);
$endH = min(24, intdiv(max($mins), 60) + 2); // +2 = 跨过那一小时再收尾
/* 🔴 v28 用户第 1 条:「今天的日程,并没有定位到红线所在的位置」。
根因:**区间里没有"现在"**,于是红线不画、定位算出来是个越界的值。
(实测:现在 22:44、日程 09:00 与 14:00 → 区间掐成 08–17,
`$nowPos = (22-8)/9 = 1.56` → `$showNow=false` → `class="ag-now"` 整个不渲染;
而定位脚本用同一个越界的 RATIO 去乘网格高度,被 clamp 到最底 → 停在底部。)
⇒ 修法不是"没红线就别定位",而是**让区间永远包含当前时刻**:
用户要的正是"一眼看到现在处在哪儿",那就必须把"现在"纳入刻度。
下面的 `$nowH` 是当前小时(服务端时区,与页面口径一致),
把它并进区间端点 —— 于是红线必然落在区间内,定位也就有了正确的目标。
⚠️ 必须在**算 $nowH 之前**完成这一步(原代码把 $nowH 放在区间之后算,
这里把它提上来,避免"改了区间却没重新算位置"这种漏改)。 */
$nowHNow = (int)date('G');
if ($nowHNow < $startH) { $startH = max(0, $nowHNow - 1); }
if ($nowHNow >= $endH) { $endH = min(24, $nowHNow + 2); }
if ($endH <= $startH) { $endH = min(24, $startH + 2); }
}
$nowH = (int)date('G');
$nowPos = ($nowH - $startH) / max(1, ($endH - $startH)); // 红线位置(0~1)
$showNow = $nowPos >= 0 && $nowPos <= 1;
/* 🔴 v30 第 2/4 条:首页日程也用**和日程模块同一套冲突分列**。
⇒ 复用 App\Calendar::conflictLanes(算法借鉴 SyncCalendar 的 TimelineUtil):
两条撞车就左右各半、三条就三分 —— 首页与日历页看到的是同一个布局。
⚠️ 必须**先按时间排序**再分列(lanes 是按传入顺序返回的):
`$todayEvents` 从路由出来已是"全天在前、其余按时刻"升序,这里再稳一次。
⚠️ 入参只挑**有时间**的那些(全天日程不进时间网格)。 */
usort($timed, static function (array $a, array $b): int {
return strcmp((string)($a['time'] ?? ''), (string)($b['time'] ?? ''));
});
$laneInfo = \App\Calendar::conflictLanes($timed);
foreach ($timed as $i => &$evRef) {
[$l, $w] = $laneInfo[$i] ?? [0.0, 1.0];
$evRef['left'] = round($l * 100, 4);
$evRef['width'] = round($w * 100, 4);
}
unset($evRef);
?>
<section class="section">
<div class="home-duo">
<?php /* ---- 左:今天的日程(日视图形式)---- */ ?>
<div class="card card-pad hd-pane" id="homeAgenda">
<div class="hd-pane-head">
<h2 class="cinema-h2">今天的日程</h2>
<?php /* 🔴 v28 用户第 2 条:「看正月这个按钮,改成:更多日程,
背景颜色和日程的主题颜色保持一致。」
(「看正月」是用户对「看整月」的口误。) */ ?>
<a class="btn btn-sm hd-more" href="<?= e(url('calendar')) ?>"
style="--hd-c: <?= e(\App\Module::color('calendar', (int)\App\Auth::familyId())) ?>;">更多日程</a>
</div>
<?php /* 🔴 v24 第 26 批 用户第 2 条(前半):「今天的日程,除了全天日程**一直显示在上面**外,
其他日程随着滚动」。
⇒ DOM 结构必须分成两层:
① `.ag-allday` **留在 pane 里、不在滚动容器内** —— 它永远钉在标题下方;
② `.hd-pane-body`(滚动容器)**只包** `.ag-grid` —— 只有时段网格随滚动。
为什么不能在同一个滚动容器里给全天区 `position: sticky`:
它在滚动区**内部**,sticky 只能粘在滚动区顶端,而滚动区顶端在标题下面 ——
滚起来时全天区会被"标题 + 滚动区"两层挤着,看起来仍然在动;
而且 sticky 需要不透明底色遮住底下滚过去的日程,与现在的浅色卡片叠色不好看。
直接把它移出滚动容器,是**零副作用**的做法。 */ ?>
<?php if (!$todayEvents): ?>
<div class="hd-pane-body">
<p class="empty-hint">今天没有安排。</p>
</div>
<?php else: ?>
<?php if ($allDay): ?>
<div class="ag-allday">
<?php foreach ($allDay as $ev): ?>
<?php /* 🔴 v43(用户第 6 条):点条目弹详情浮层(不直接跳页)。 */ ?>
<?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="ag-allday-item" href="<?= e((string)$ev['href']) ?>"
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'>
<span class="dash-dot" style="background:<?= e((string)$ev['color']) ?>;"></span>
<span class="ag-ad-t">全天</span>
<span class="ag-ad-w"><?= e((string)$ev['title']) ?></span>
<span class="dash-cal" style="background:<?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="hd-pane-body" data-ag-scroll>
<div class="ag-grid">
<?php for ($h = $startH; $h < $endH; $h++): ?>
<div class="ag-row">
<span class="ag-hour"><?= sprintf('%02d:00', $h) ?></span>
<span class="ag-line"></span>
</div>
<?php endfor; ?>
<?php /* 当前时刻红线:用户原话「需要在当前的时间或者是时刻那里显示一条红线」 */ ?>
<?php if ($showNow): ?>
<div class="ag-now" style="top: <?= number_format($nowPos * 100, 2) ?>%">
<span class="ag-now-dot"></span>
</div>
<?php endif; ?>
<?php /* 日程块:按"相对第一个刻度的小时数"定位(与日视图同一套比例)---- */ ?>
<?php
/* 🔴 v30 第 2 条:「首页上的今天的日程,用色块表示,和日程保持一致。」
用户要的是**和日程模块(周/日视图)一样的实心色块**,而不是原来的
"白底 + 左边一条色线" —— 后者色只占 3px,一眼看不出是哪个日历本。
⇒ 三处对齐日程模块的 `.tg-block`:
① 底色 = 该日程所属日历本的颜色(不是主题色);
② 白字(`.tg-block` 就是 `color:#fff`);
③ 冲突错开(left/width 由 Calendar::conflictLanes 算,见 index.php)。
⚠️ 深浅:`--tg-*` 那几个本子的颜色都是中低明度,白字压得住;
但用户可能自定义成很浅的色 —— 所以标题那一行加 `text-shadow`
兜底(比"再加一层半透明黑罩"更不破坏本色)。
⚠️ 高度用 `min-height` 而不是固定值:15 分钟的日程也要看得见
(与 .tg-block 的 15 分钟最短块口径一致)。 */
foreach ($timed as $ev):
$p = explode(':', (string)$ev['time']);
$evMin = ((int)($p[0] ?? 0)) * 60 + ((int)($p[1] ?? 0));
$topPct = (($evMin / 60) - $startH) / max(1, ($endH - $startH)) * 100;
$topPct = max(0, min(96, $topPct));
// 结束时刻:缺省时按"下一件事的起点"或 30 分钟(与日历口径一致)
$et2 = (string)($ev['end_time'] ?? '');
if ($et2 !== '' && preg_match('/^(\d{1,2}):(\d{2})/', $et2, $mm2)) {
$endMin = ((int)$mm2[1]) * 60 + ((int)$mm2[2]);
} else {
$endMin = $evMin + 30;
}
if ($endMin <= $evMin) { $endMin = $evMin + 30; }
$endMin = min($endMin, $endH * 60);
$hPct = ($endMin / 60 - $startH) / max(1, ($endH - $startH)) * 100 - $topPct;
$hPct = max(2.2, $hPct); // 至少能塞下一行
?>
<?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="ag-ev" href="<?= e((string)$ev['href']) ?>"
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'
style="background: <?= e((string)$ev['color']) ?>;
top: <?= number_format($topPct, 2) ?>%;
height: <?= number_format($hPct, 2) ?>%;
<?php /* 🔴 v37 用户第 3 条:「首页上今天的日程…如果它分了三列
或者是几列,这三列之间的距离或者是空隙非常非常的大,
是分开的,中间有非常大的空隙。我看了一下日程里面的那个
显示,那两个色块就是紧挨着的。所以…应该让它们紧挨着。」
根因(与日历页 .tg-block 逐字对比出来的):
· 日历页写的是 `left: calc(2px + L%)` / `width: calc(W% - 4px)`
—— 只留 2px+2px 的缝,所以色块**紧挨**;
· 这里却写死了 `calc(46px + L%)` / `calc(W% - 50px)`
—— 每块左右各扣 23px/25px。两列时那 46px 的扣减
**没法均分**,全挤进了两块之间的缝里 →
实测缝 ≈ 50px(等于把 46px 全加在了中间),
日历页只有 ≈ 4px。
⇒ 改法与日历页对齐:
left = 46px(给小时刻度)+ 列内的 2px
width = 列宽 - 4px(左右各 2px)
这样列间缝恒为 4px(紧挨),而且与"放得下多少列、
每列多宽"的解耦是干净的 —— 列宽由 width% 决定,
缝由那 4px 决定,两者不再互相污染。 */ ?>
left: calc(46px + <?= (float)($ev['left'] ?? 0) ?>% + 2px);
width: calc(<?= (float)($ev['width'] ?? 100) ?>% - 4px);"
title="<?= e((string)$ev['title']) ?>"
data-cal="<?= (int)$ev['cal'] ?>">
<span class="ag-ev-t"><?= e((string)$ev['time']) ?></span>
<span class="ag-ev-w"><?= e((string)$ev['title']) ?></span>
<span class="ag-ev-cal"><?= e((string)$ev['calname']) ?></span>
</a>
<?php endforeach; ?>
</div><?php /* /.ag-grid */ ?>
</div><?php /* /.hd-pane-body(滚动区,只含时段网格) */ ?>
<?php endif; ?>
</div><?php /* /.hd-pane */ ?>
<?php /* ---- 右:进行中的任务(标题 + 状态 + 看板名)---- */ ?>
<div class="card card-pad hd-pane" id="homeTasks">
<div class="hd-pane-head">
<h2 class="cinema-h2">近期任务</h2>
<?php /* 🔴 v28 用户第 3 条:「『手头在做』改成『近期任务』,『任务看板』这个按钮
改成『全部任务』,按钮颜色和任务的主题颜色保持一致。」 */ ?>
<a class="btn btn-sm hd-more" href="<?= e(url('tasks')) ?>"
style="--hd-c: <?= e(\App\Module::color('tasks', (int)\App\Auth::familyId())) ?>;">全部任务</a>
</div>
<div class="hd-pane-body">
<?php /* 用户第 6 条:「所有的任务,前面都带一个复选框,在任何地方,点这个复选框,
这个任务就完成了。」→ 首页这一块也算"任何地方",
所以每行前面也有复选框,点了就地完成(AJAX,不跳页)。
表单与 JS 在页面末尾各一份(见 home.php 的 taskDoneForm 段)。 */ ?>
<?php if (!$tasks): ?>
<p class="empty-hint">没有进行中的任务。</p>
<?php else: ?>
<ul class="dash-list tk-list">
<?php foreach ($tasks as $t): ?>
<?php $late = \App\Task::isOverdue($t['due_at'] ?? null); ?>
<?php
/* 🔴 v32 第 4 条(用户 2026-10-08):「首页,近期任务,任务的文字和前面的圆点,
颜色并没有跟**看板颜色**一致,这个需要修正。」
⚠️ v30 那轮我把它改成了 `--tk-c`(= **任务模块**的主题色),
当时理解成"用户说的主题色"。**理解错了** —— 用户要的是
「这条任务属于哪个**看板**」的颜色(任务模块自己早就支持
"给看板选颜色",用户原话在第 26 批:「任务在设置看板的时候,
可以选择颜色,这样在首页列表中显示的时候,就可以更加直观的
看到他属于哪个看板。」)。
⇒ 用 `Task::boardColorById(家庭, 看板id)`:
看板没设色时它按**看板名派生**一个稳定色(同名永远同色),
所以这里不需要任何回退分支。
⚠️ 逾期仍用红色(状态语义,不能被看板色盖掉)——
但那是"整行变红",与"没逾期时跟看板一致"并不冲突。
⚠️ 没归看板的(board_id 为空)走"默认看板"的派生色,
与任务模块列表页看到的完全一致(同一函数)。 */
$tkColor = \App\Task::boardColorById((int)\App\Auth::familyId(),
(int)($t['board_id'] ?? 0) ?: null);
?>
<li class="dash-row tk-row" data-task-row="<?= (int)$t['id'] ?>"
style="--tk-c: <?= e($tkColor) ?>;">
<?php
$tkId = (int)$t['id']; $tkStatus = (string)($t['status'] ?? 'todo');
$tkBack = 'home'; $tkStatic = !\App\Auth::canWrite();
require __DIR__ . '/_task_check.php';
?>
<span class="dash-dot" style="background: <?= e($late ? 'var(--red)' : 'var(--tk-c)') ?>;"></span>
<?php /* 🔴 v43(用户第 6 条):点任务标题也弹详情浮层(不直接跳页)。
要点在**标题那个 <a>** 上而不是整行 ——
行里还有复选框(勾一下完成)与其它链接,
整行可点会让"勾选"和"打开"互相抢。 */ ?>
<a class="dash-what" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>"
data-fb-entry='<?= fb_entry_json([
'title' => (string)$t['title'],
'status_word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
'note' => (string)($t['note'] ?? ''),
], url('tasks/show', ['id' => (int)$t['id']]),
\App\Auth::canWrite() ? url('tasks/edit', ['id' => (int)$t['id']]) : '',
[['截止', ($t['due_at'] ?? '') !== '' ? substr((string)$t['due_at'], 0, 16) : ''],
['看板', trim((string)($t['board_name'] ?? ''))]]) ?>'
style="color: <?= e($late ? 'var(--red)' : $tkColor) ?>;">
<?= e((string)$t['title']) ?>
</a>
<?php /* 用户第 3 条:「除了显示任务的标题之外,还要显示他的状态。
比如说正在进行中待确认等等。」 */ ?>
<span class="kb-st is-<?= e((string)($t['status'] ?? 'todo')) ?> is-static">
<?= e(\App\Task::statusWord((string)($t['status'] ?? 'todo'))) ?>
</span>
<?php /* 用户第 3 条:「还要显示他所处的看板的名称,就是他现在被放在哪一个看板里面」 */ ?>
<?php if (trim((string)($t['board_name'] ?? '')) !== ''): ?>
<?php /* 🔴 v24 第 26 批 用户第 3 条(后半):「任务在设置看板的时候,可以选择颜色,
这样在首页列表中显示的时候,就可以更加直观的看到他属于哪个看板。」
⇒ 徽标**同时带颜色**:左边一个小圆点(看板色)+ 看板名。
为什么不用"整块背景染色":首页这一行已经有状态徽标、看板徽标、逾期等几个
色块,整块染色会互相打架、反而看不出哪个色对应哪个语义。
一个小圆点信息量足够、又不抢戏。 */
$bColor = \App\Task::boardColor($t['board_color'] ?? null, (string)$t['board_name']); ?>
<span class="tk-board" style="--bc: <?= e($bColor) ?>;">
<i class="tk-board-dot" aria-hidden="true"></i><?= e((string)$t['board_name']) ?>
</span>
<?php endif; ?>
<?php if ((int)($t['child_total'] ?? 0) > 0): ?>
<span class="dash-tag"><?= (int)$t['child_done'] ?>/<?= (int)$t['child_total'] ?></span>
<?php endif; ?>
<?php if (($t['due_at'] ?? '') !== ''): ?>
<span class="tk-due"><?= e(\App\Task::dueWord($t['due_at'] ?? null)) ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
</div>
</section>
<?php /* ==================== 通知消息(v30 第 5 条)====================
用户原话:「…发一个通知,通知出现在首页中,**往年今天的前面**。」
⇒ 位置是硬性要求:必须在「往年的今天」**之前**。
⚠️ 没有通知时**整块不渲染** —— 首页是"每天第一眼看的地方",
不要用一个空盒子占掉一屏(与"今天没有安排"那种信息位不同:
通知本来就是事件驱动的,没有就真的不该出现)。 */ ?>
<?php if (!empty($notifs)): ?>
<section class="section">
<div class="page-head" style="margin-bottom:12px">
<h1 style="font-size:15px">
通知消息
<?php if ($notifUnread > 0): ?><span class="sub"><?= (int)$notifUnread ?> 条未读</span><?php endif; ?>
</h1>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('notifications')) ?>">全部通知</a>
</div>
</div>
<div class="card card-pad">
<ul class="ntf-list ntf-list-home">
<?php foreach ($notifs as $n): ?>
<?php
$actorName = \App\Nickname::display(
(int)$meId,
['name' => $n['actor_name'] ?? '', 'my_nickname' => $n['actor_nick'] ?? '']
);
if (trim((string)$actorName) === '') { $actorName = '有人'; }
?>
<li class="ntf-row ntf-row-<?= e((string)($n['kind'] ?? '')) ?><?= empty($n['is_read']) ? ' is-unread' : '' ?>">
<span class="ntf-kind" aria-hidden="true"><?= \App\Notification::icon((string)($n['kind'] ?? '')) ?></span>
<img class="ntf-av" src="<?= e(avatar_url($n['actor_avatar'] ?? null)) ?>" alt="">
<a class="ntf-main" href="<?= e(\App\Notification::href($n)) ?>">
<span class="ntf-title">
<b><?= e((string)$actorName) ?></b><?= e(\App\Notification::bodyWithoutActor((string)$n['title'])) ?>
</span>
<span class="ntf-time"><?= e(fb_time_ago((string)$n['created_at'])) ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
</section>
<?php endif; ?>
<?php /* ==================== ③ 往年的今天 ==================== */ ?>
<section class="section">
<div class="page-head" style="margin-bottom:12px">
<?php /* v24 第 19 条:只认「往年的今天」,不再退回同月 —— 见 home_memories() 的说明。 */ ?>
<h1 style="font-size:15px">
往年的今天
<span class="sub"><?= e(date('n 月 j 日')) ?></span>
</h1>
</div>
<?php if (empty($memories['rows'])): ?>
<div class="card card-pad empty">
<b>往年的这一天还没有记录</b>
日子还长,等有了第一次,明年这时候就会自己冒出来。
</div>
<?php else: ?>
<div class="card card-pad">
<ul class="dash-list">
<?php foreach ($memories['rows'] as $mm): ?>
<li class="dash-row">
<span class="dash-dot" style="background: <?= $mm['kind'] === 'trip' ? '#3F7D5A' : '#7A4E8C' ?>;"></span>
<span class="dash-when"><?= (int)$mm['years_ago'] ?> 年前</span>
<a class="dash-what" href="<?= e((string)$mm['href']) ?>">
<?= $mm['kind'] === 'trip' ? '🧳 ' : '🎬 ' ?><?= e((string)$mm['title']) ?>
</a>
<span class="dash-tag"><?= e(substr((string)$mm['starts_at'], 0, 10)) ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</section>
<?php /* ==================== 首页任务复选框的表单与脚本(用户第 6 条)====================
「在任何地方点复选框,这个任务就完成了」—— 首页也算"任何地方"。
与任务看板同一套做法:**表单全页一份**,行上只放 data-tk-done。
⚠️ 改完**不摘掉这一行**:用户点完正想看自己刚完成什么,
立刻消失会让人怀疑"是不是点到删除了"。刷新时由服务端决定它还在不在。 */ ?>
<?php if (!empty($tasks) && \App\Auth::canWrite()): ?>
<form method="post" action="<?= e(url('tasks/status')) ?>" id="taskDoneForm" class="hidden-form">
<?= csrf_field() ?>
<input type="hidden" name="id" id="doneId">
<input type="hidden" name="status" id="doneStatus">
<input type="hidden" name="back" id="doneBack" value="home">
</form>
<script>
(function () {
var form = document.getElementById('taskDoneForm');
if (!form) { return; }
var staticWord = {todo: '待办', doing: '进行中', done: '已完成'};
function say(msg, ok) {
var host = document.querySelector('.flash');
if (!host) {
host = document.createElement('div');
host.className = 'flash';
var main = document.querySelector('main') || document.body;
main.insertBefore(host, main.firstChild);
}
var el = document.createElement('div');
el.className = 'flash-item' + (ok ? '' : ' err');
el.setAttribute('role', 'status');
el.textContent = msg;
host.insertBefore(el, host.firstChild);
setTimeout(function () { if (el.parentNode) { el.parentNode.removeChild(el); } }, 3200);
}
Array.prototype.forEach.call(document.querySelectorAll('.tk-check[data-tk-done]'), function (b) {
b.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
var now = b.getAttribute('data-tk-status') || 'todo';
var next = (now === 'done') ? 'todo' : 'done';
document.getElementById('doneId').value = b.getAttribute('data-tk-done');
document.getElementById('doneStatus').value = next;
document.getElementById('doneBack').value = b.getAttribute('data-tk-back') || 'home';
if (!window.fetch || !window.FormData) { form.submit(); return; }
b.disabled = true;
fetch(form.getAttribute('action'), {
method: 'POST', body: new FormData(form), credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) { return r.json().catch(function () { return null; }); })
.then(function (d) {
b.disabled = false;
if (!d || !d.ok) { form.submit(); return; }
b.setAttribute('data-tk-status', d.status);
b.classList.toggle('is-done', d.status === 'done');
b.setAttribute('aria-pressed', d.status === 'done' ? 'true' : 'false');
// 状态徽标(那行「进行中」)跟着换,否则勾上了徽标还写着"进行中"
var row = b.closest('[data-task-row]');
var badge = row ? row.querySelector('.kb-st') : null;
if (badge) {
badge.className = 'kb-st is-' + d.status + ' is-static';
badge.textContent = staticWord[d.status] || d.word;
}
say(d.msg || '已更新。', true);
}).catch(function () { b.disabled = false; form.submit(); });
});
});
})();
</script>
<?php endif; ?>
<?php /* ==================== 日程滚动区:进场定位到当前时刻(v24 第 26 批 用户第 2 条)======
用户原话:「每次进来,都要定位到当前时刻,就是红线出现的位置,让红线在中间。」
⇒ 打开首页时把**滚动容器**滚到一个位置,使"当前时刻"落在可视区**正中**。
⚠️ 触发条件:**今天有日程就定位**(只要 `$todayEvents` 非空,页面上就有
`.ag-grid` 与红线)。曾经写成 `if (!$todayEvents || empty($timed))`,
后果是**今天只有全天日程 / 全天日程都没有分时段的那种日子,脚本整段不输出**
—— 而网格(0–24h)和红线照样渲染,于是"红线在,页面却停在最上面"
(v29 用户第 1 条仍然报「没有定位到红线」,实测 `scrollTop=0`、脚本不在页面上,
就是这个条件卡掉的;有分时段日程时定位其实完全正确、偏差 0px)。
⇒ 只判"有没有网格可滚",别判"是不是分时段日程"。
⚠️ 用 `scrollTop = 目标偏移 - 容器高/2`,**不要**用 `scrollIntoView({block:'center'})`:
它会**连带滚动外层页面**(把整个浏览器窗口也滚下去),
用户一进首页就被拽到"页面中部" —— 那比不定位更糟。
直接给容器赋 `scrollTop` 只动这一个盒子,外层纹丝不动。
⚠️ 容器"没得滚"时(内容比容器矮)赋任何值都是 0,天然安全,不必特判。
⚠️ 目标位置**由服务端算好**传进来(`$nowPos`),不在 JS 里再算一遍时间 ——
两处各算一次迟早不一致(服务端用的是站点时区,JS 用的是浏览器时区)。 */ ?>
<?php if (!$todayEvents): ?>
<?php /* 今天一条日程都没有 → 页面是「今天没有安排」的空态,没有网格可滚 */ ?>
<?php else: ?>
<script>
(function () {
var box = document.querySelector('[data-ag-scroll]');
if (!box) { return; }
// 红线(在的话)优先;不在就用服务端算好的 nowPos 现算一个位置。
var line = box.querySelector('.ag-now');
var RATIO = <?= json_encode(round($nowPos, 6)) ?>; // 0~1(可能落在区间外)
function targetOffset() {
if (line) { return line.offsetTop; }
var grid = box.querySelector('.ag-grid');
if (!grid) { return 0; }
// ⚠️ 用**排掉全天区之后**的内容高度算:全天区现在不在滚动容器里,
// 所以 grid 的高度就是"整条时间轴"的高度(与服务端算 nowPos 的口径一致)。
return grid.offsetHeight * RATIO;
}
function centerOnNow() {
// offsetTop 相对**定位祖先**(.ag-grid,position:relative),即"内容坐标"
box.scrollTop = Math.max(0, targetOffset() - (box.clientHeight / 2));
}
centerOnNow();
/* ⚠️ 再补两拍:字体加载完 / 浏览器恢复滚动位置(back-forward cache)之后,
内容高度会变,第一次算出来的 scrollTop 可能偏。
用 rAF 而不是 setTimeout:rAF 在排版完成后跑,时序更准;
两拍是为了覆盖"布局在渲染后才最终确定"的那一帧。 */
if (window.requestAnimationFrame) {
requestAnimationFrame(function () { requestAnimationFrame(centerOnNow); });
}
})();
</script>
<?php endif; ?>