Files
FamilyBook/templates/home.php
T
laoyang 37ba86321b v29 第 29 批(9 条):日程定位/复选框提示/名册错乱三处真因修复 + 读书落盘 + 导航四设置 + 笔记阅读态与共享
前 4 条是"上批改了但用户仍报"的,这次先拿真机证据再动手,三处真因如下:

1. 首页没定位到红线 —— 真根因:定位脚本的 PHP 条件是
   `if (!$todayEvents || empty($timed))`,**今天只有全天日程时整段脚本不渲染**
   (实测 hasScript=False、scrollTop=0),而 .ag-grid(0–24h) 与红线都在。
   有分时段日程时定位其实完全正确(偏差 0px)。已改成 `if (!$todayEvents)`。
2. 任务复选框提示还在 —— 真根因:`_task_check.php` 里那句无障碍文字
   `<span class="visually-hidden">` 用的类名**在 app.css 里从来没有定义过**
   (项目一直只用 .sr-only)→ 没有规则 = 普通 span = 直接可见。
   已把两个类名合并到同一条规则(clip 隐藏,仍可读屏)。
3. 名册完全错乱 —— 真根因:v29 重写平铺列表时**漏了一个 `</div>`**(闭合 .person),
   浏览器把下一张卡嵌进上一张里,实测 .person-main 只剩 24~151px(本该 ~800px)。
   `php -l` 与全部静态断言都抓不到,只有真机量 DOM 才现形。已补上闭合。
   另把 v28 引入的多列 grid 改回单列(它与 content-visibility 冲突)+窄屏动作区折行。
4. 读书慢 —— 按用户给的思路「做一个同步,然后落盘」:
   新增 weread_snapshots 表 + App\WeReadCache(按人+数据集存 JSON);
   渲染路径改走 notebooksCached()/readStatsCached()(永不联网);
   同步从"渲染之前"挪到"**响应之后**"(queueBackgroundSync + fastcgi_finish_request)。
5. 导航设置改成右上角**下拉菜单 + 四视图**(addr/general/board/engine),
   表单带 back 域 → 保存后回原来那一项。
   🔴 顺带修掉一个真 bug:`if (!$inSettings && $canWrite)` 的 endif 漂到了文件末尾,
   把 board/engine 两个视图也包进了"非设置态" → 打开时标题对、**内容是空的**。
6. 笔记列表缩略图:cover → contain + 固定高度(按比例完整显示,不裁切)。
7. 笔记段落编辑器:加 Markdown 工具条 + 斜线命令(行首打 /co → 回车补全代码块),
   两者共用同一份命令表。
8. 笔记点进来默认**阅读态**(图文混排、附件在最下方、图片四档宽度、点「编辑」才可改);
   清单块并入 Markdown —— 但**历史 list 块转写成 `- 项` 文本,老数据不丢**。
9. 笔记共享改成**逐人 + 权限**(note_shares,与 resource_shares 同构);
   可见性收敛成全站唯一的 Note::VIS_COND;迁移用一次性哨兵。
2026-10-08 10:54:43 +08:00

520 lines
30 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;
?>
<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): ?>
<a class="ag-allday-item" href="<?= e((string)$ev['href']) ?>">
<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 foreach ($timed as $ev): ?>
<?php
$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));
?>
<a class="ag-ev" href="<?= e((string)$ev['href']) ?>"
style="top: <?= number_format($topPct, 2) ?>%; border-left-color: <?= e((string)$ev['color']) ?>;">
<span class="ag-ev-t"><?= e((string)$ev['time']) ?></span>
<span class="ag-ev-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 /* /.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); ?>
<li class="dash-row tk-row" data-task-row="<?= (int)$t['id'] ?>">
<?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(--brand)') ?>;"></span>
<a class="dash-what" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>">
<?= 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 /* ==================== ③ 往年的今天 ==================== */ ?>
<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; ?>