v30 后半(5 条):导入框选中反馈+单选 / 首页日程改色块 / 近期任务主题色 / 时间冲突错开 / 通知消息模块

用户 2026-10-08 第二批 5 条。

1. 导入日程选中文件后没有任何反应 + 不允许多选
   `_stage_drop.php` 现在有两种模式:暂存型(附件,拖进来即刻上传)与导入型
   (`$sdStage=false`,写回 input.files、随表单提交、PHP 读完即弃)。
   暂存型框靠 queueDrop 画缩略图,而**导入型框选中文件后什么都不显示** ——
   框里那句「点击或拖动进行上传」从头到尾不变,用户看不出"到底选上没有"。
   改:app.js 新增 bindFileEcho(),只给**非暂存框**补 change 监听,
   选中后框里的话换成文件名 + 图标变 ✓ + `.upbox.is-picked` 稳定态;
   取消选择则还原原始文案(不是空字符串)。单选由 `$sdMultiple=false` 保证。

2. 首页「今天的日程」用色块表示,和日程保持一致
   原来那套是"白底卡片 + 左边一条 3px 色线",色只占一条线,看不出是哪个日历本。
   改:与日历页 `.tg-block` 同款实心色块(底色=日历本色,白字,圆角,无阴影),
   left/width/top/height 全部由内联给出(冲突分列 + 真实时长)。
   ⚠️ `.ag-ev` 的 CSS 里去掉 `right` —— 绝对定位下 left+right+width 三者同时存在是
      过约束,内联 width 会失效(冲突就不会错开了)。

3. 首页「近期任务」名称与圆点走任务模块主题色
   原来用 `var(--brand)` —— 那是**当前页面**(首页)的主题色,不是任务模块的;
   用户改过模块配色时两者不一致(首页砖红、任务别的色),正是他看到的"不一致"。
   改:两处都走 `--tk-c`(= Module::color('tasks')),与右上角「全部任务」按钮同源。
   逾期仍用红色(状态语义,不能被主题色盖掉)。

4. 日程时间冲突错开(算法借鉴 SyncCalendar)
   用户指定去读 SyncCalendar 的算法。落地自
   `entry/src/main/ets/common/TimelineUtil.ets` 的 build()/groupsOf():
   ① 划分冲突组(与组内最晚结束仍重叠就并入);② 组内贪心分列(区间图着色),
   列数 = **每组各自的最大同时重叠数**(sync 原注释点名的反例:传递性重叠但
   B/C 不重叠 → 只该 2 列)。落成 `App\Calendar::conflictLanes()`(PHP,判据一字未改)。
   ⚠️ 两处比原算法更细,都有单测钉住:
     · 列数按**组**算 —— 否则一个孤立日程会被别处的三方冲突连累成 1/3 宽;
     · "缺结束时间"不能一律按 30 分钟参与冲突(会与后面所有日程假冲突),
       改为优先拿**同列下一条的起点**当它的视觉结束 → 因此必须**先分列再算高度**。
   首页与日历页共用同一套(用户第 2 条要求"和日程保持一致")。

5. 通知消息模块(Schema v30 → v31)
   不在导航显示,入口在头像下拉里、**排在个人设置之前**,名字「通知消息」;
   有人在观影/旅程加我时发通知;通知出现在首页、**往年今天之前**。
   新增:app/Notification.php + notifications 表(唯一键
   (person_id,target_type,target_id) —— 参与人是整组替换,没有它改三次标题就收三条)+
   ?r=notifications{,/del,/clear} + templates/notifications/index.php + 头像下拉未读角标 +
   app/helpers.php 的 fb_time_ago()。
   ⚠️ 三条设计判断:不进 Module::LIST(进去就会被顶栏渲染、被"模块显示与排序"当开关);
      只在 person_id !== actor_id 时发(不给自己发,"通知"≠流水账);
      移出参与人**撤销未读**、**保留已读**(未读是没送达的提醒,已读是看过的历史)。

验证(全部 0 失败):e2e 1478/0(+14 条新断言)、升级 153/0、vCard 40 项、城市 32/0、
几何 428/0 + 移动 680/0 + 交互 92/0、b24/b27/b28/b29 真机全绿;
冲突分列算法单测 12/0(含 SyncCalendar 点名的反例)、
新探针 _b30_ui2 16/0 + _b30_notify 16/0 + _b30_import 26/0、
反向验证 18/0(五条各自改回旧行为 → 断言全红)、
部署脚本新增 9 条守卫 + 守卫证伪 11/0。线上 11 文件 SHA256 一致,Schema v31 已升级。
This commit is contained in:
2026-10-08 17:41:30 +08:00
parent 91c55038c9
commit 2a10df2557
11 changed files with 918 additions and 31 deletions
+118
View File
@@ -1080,6 +1080,124 @@ final class Calendar
return $out;
}
/**
* 🌟 时间冲突分列(v30 第 4 条)—— 「两个日程撞在一起不再堆叠,要错开画」。
*
* 用户原话:「日程中,如果两个事件时间上有冲突了,现在是堆叠在一起,应该错开,
* 显示两个色块,如果三个冲突了,就是三个色块,错开显示。」
* 并指定「这个显示效果,在 SyncCalendar 这个代码中有算法,你去读一下,借鉴一下」。
*
* ⇒ 算法逐行对照 SyncCalendar/entry/src/main/ets/common/TimelineUtil.ets 的
* build() + groupsOf() 搬过来(那边跑在 ArkTS 上,这里是 PHP,但**判据一字未改**):
* ① 先划分「冲突组」:按开始时间升序,一条与组内**最晚结束**还存在重叠
* (`start < endMax`)就并进这一组 —— 这是"传递性连通",注意它会把
* 「A 7:30-9:30 / B 8:20-9:00 / C 9:15-9:55」并成一组;
* ② 组内**贪心分列**(区间图着色):每条放进"结束时间 <= 它开始时间"的
* **编号最小**的那一列;开不出新列时才加一列。
* ⇒ 列数 = 该组的**最大同时重叠数**,不是组内条数 ——
* 上面那个例子里 B 与 C 并不重叠,可共用一列,所以只需 2 列而不是 3 列。
* 这正是 SyncCalendar 注释里特意点出的"旧算法按传递性重叠分 3 列"的错处。
* ③ 宽度 = 1/列数,左偏移 = 列号/列数 —— 于是 2 条冲突各占一半、3 条各占三分之一。
*
* ⚠️ 每条日程的"结束分钟"若缺失或 <= 开始(跨天日程的第二天、或用户填反了),
* 按 30 分钟的最短可见块参与分列 —— 与 build_time_grid 的口径保持一致,
* 否则"没有结束时间"的那些会被算成零长、永远不参与冲突。
* ⚠️ **只在有时间的事件之间判冲突**:全天日程不进时间网格(它们挂顶上那一行)。
*
* @param array $events 某一天的时间型事件(元素含 time / end_time,单位 'HH:MM')
* @return array 与入参同序的 [left, width](都是 0~1 的比例;无冲突时是 [0,1])
*/
public static function conflictLanes(array $events): array
{
$n = count($events);
if ($n <= 1) {
return array_fill(0, $n, [0.0, 1.0]);
}
// ---- 前缀扫描动作:把每条事件换成一个"动作"(start 记 +1、end 记 -1)----
// 这样"任意一点上有几条重叠"就是同一个坐标上的动作个数 —— 这正是
// SyncCalendar 的贪心分列(laneEnd <= start ⇒ 复用该列)的等价表述,
// 但**完全不需要保留 laneEnd 数组**,也就绕开了浮点比较。
// ⚠️ 不能简单地按"起点同时刻"分组:结束点也要参与,且同时刻的 end 必须先于 start
// 处理(否则"9:00 结束的"与"9:00 开始的"会被算成重叠)。
// ---- ① 归一成 [下标, 开始, 结束](结束缺失/倒挂 → 30 分钟最短块,同上口径)----
$rows = [];
foreach ($events as $i => $e) {
$s = self::minutesOf((string)($e['time'] ?? ''));
if ($s === null) {
continue;
}
$en = self::minutesOf((string)($e['end_time'] ?? ''));
if ($en === null || $en <= $s) {
$en = $s + 30;
}
$rows[] = ['i' => $i, 's' => $s, 'e' => min($en, 1440)];
}
if (!$rows) {
return array_fill(0, $n, [0.0, 1.0]);
}
// 按开始时间升序(同开始则长的在前 —— 与 SyncCalendar 的稳定排序一致)
usort($rows, static function (array $a, array $b): int {
return $a['s'] <=> $b['s'] ?: $b['e'] <=> $a['e'];
});
// ---- ② 划分冲突组:与组内**最晚结束**仍重叠(start < endMax)就并进本组 ----
$groups = [];
$cur = [];
$endMax = -1;
foreach ($rows as $r) {
if ($cur && $r['s'] >= $endMax) {
$groups[] = $cur;
$cur = [];
$endMax = -1;
}
$cur[] = $r;
$endMax = max($endMax, $r['e']);
}
if ($cur) {
$groups[] = $cur;
}
// ---- ③ 组内贪心分列:每列记"最后结束分钟",放进编号最小的可用列 ----
// ⚠️ 列数是**每组各自**的(组与组之间时间不重叠,可互相复用 0 号列)——
// 所以一个"孤立日程"仍然是整宽,不会被别处的三方冲突连累成 1/3 宽。
$out = [];
for ($i = 0; $i < $n; $i++) {
$out[$i] = [0.0, 1.0];
}
foreach ($groups as $g) {
$laneEnd = []; // 每列最后结束分钟
$laneOf = []; // 本组第 k 条 → 列号
foreach ($g as $k => $r) {
$lane = -1;
foreach ($laneEnd as $li => $le) {
if ($le <= $r['s']) { $lane = $li; break; }
}
if ($lane < 0) {
$lane = count($laneEnd);
$laneEnd[] = $r['e'];
} else {
$laneEnd[$lane] = $r['e'];
}
$laneOf[$k] = $lane;
}
$cols = max(1, count($laneEnd));
foreach ($g as $k => $r) {
$out[$r['i']] = [$laneOf[$k] / $cols, 1 / $cols];
}
}
return $out;
}
/** 'HH:MM' → 分钟数;空/不合法返回 null(第 4 条的分列用) */
private static function minutesOf(string $t): ?int
{
if (preg_match('/^(\d{1,2}):(\d{2})/', trim($t), $m)) {
return ((int)$m[1]) * 60 + (int)$m[2];
}
return null;
}
/** 把 [start, end] 夹到 [from, to] 里,逐天列出(含端点) */
private static function eachDay(string $start, string $end, string $from, string $to): array
{
+213
View File
@@ -0,0 +1,213 @@
<?php
/**
* 通知消息(v30 第 5 条)。
*
* 用户原话:「引入通知模块,这个模块,不在导航显示,在个人头像点击那里显示,
* 放在个人设置前面,名字:通知消息。就是当有人在观影、旅程等地方
* 加我的时候,发一个通知,通知出现在首页中,往年今天的前面。」
*
* ────────────────────────────────────────────────────────────────
* 五条设计判断(每条都对应一个"不这么做就会出的问题"):
*
* ① **不在 Module::LIST 注册**。它不是"一个可浏览的数据模块",而是"给我看的消息"。
* 注册进 LIST 就会被 `Module::navModules()` 拿去渲染顶栏(用户明确要求"不在导航显示"),
* 还会出现在个人设置的"模块显示与排序"里 —— 那里对它是没有意义的开关。
* ⇒ 它是一个**独立页面**(`?r=notifications`),入口只在头像下拉里。
*
* ② **只通知"别人把我也加进去了",不通知"我自己建的"**。
* 这是"通知"与"流水账"的分界:我自己建一条观影、列表里带上家人,
* 不该给自己发一条"有人加了你"。所以只对 `personId !== actorId` 的收件人写入。
*
* ③ **按"新增"发,按"删除"撤销**。整组替换(`saveAttendees` 先 DELETE 再 INSERT)
* 会产生一个很具体的坏情况:用户把参与人从 [A,B] 改成 [A],
* 如果不撤销,B 的通知还挂在那儿 —— "我明明被移出去了,怎么还说加了我"。
* ⇒ `syncForTarget()` 把"这次要保留的收件人"传进来,多出来的**按未读删掉**。
* ⚠️ 已读的**不删**(那是用户已经看过的历史,删了像"消息会自己消失");
* 未读的才是"还没送达的提醒",撤回它才是对的。
*
* ④ **一次编辑不重复发**。UNIQUE KEY (person_id, target_type, target_id)
* + INSERT IGNORE:同一件事反复保存只会有一条通知。
* 否则用户改三次标题就收到三条"加了你"。
*
* ⑤ **不做邮件/推送**。用户要的是"站内消息"("通知出现在首页中")。
* 发邮件要 SMTP 配置、还要防打扰,那是另一个模块的事,这里只做站内。
*/
declare(strict_types=1);
namespace App;
final class Notification
{
/** 宿主类型(与 attachments.target_type 同一套词,便于将来复用跳转逻辑) */
public const MOVIE = 'movie';
public const TRIP = 'trip';
/** 列表一次最多显示多少条(首页那块只取前几条,见 unreadCount / recent) */
public const PAGE_SIZE = 50;
/**
* 同步某个宿主的"加人"通知。
*
* 调用时机:观影/旅程保存参与人**之后**(见 Movie::saveAttendees / Trip 的对应处)。
*
* @param int $familyId
* @param string $type 宿主类型('movie' / 'trip')
* @param int $targetId 宿主 id
* @param string $title 宿主标题(通知里要显示"《xxx》")
* @param int $actorId 动作发出者(谁把别人加进去的)
* @param int[] $keepIds 这次**保留**的参与人(总集)
* @param string $href 点通知跳哪儿(相对 url() 的 r 值,如 'movies/show')
* @param array $params href 的查询参数(如 ['id'=>12])
*/
public static function syncForTarget(int $familyId, string $type, int $targetId,
string $title, int $actorId, array $keepIds,
string $href, array $params = []): void
{
if ($targetId <= 0 || $familyId <= 0) {
return;
}
$db = Database::get();
// ---- ① 收件人 = 保留列表 - 动作发出者(不给自己发,见头部第 ②条)----
$keep = [];
foreach ($keepIds as $pid) {
$pid = (int)$pid;
if ($pid > 0 && $pid !== $actorId) {
$keep[$pid] = true;
}
}
// ---- ② 撤销:这次不在保留列表里的**未读**通知,删掉(见头部第 ③条)----
$st = $db->prepare(
'SELECT id, person_id FROM notifications
WHERE target_type = ? AND target_id = ? AND is_read = 0'
);
$st->execute([$type, $targetId]);
$del = $db->prepare('DELETE FROM notifications WHERE id = ?');
foreach ($st->fetchAll() as $row) {
if (!isset($keep[(int)$row['person_id']])) {
$del->execute([(int)$row['id']]);
}
}
// ---- ③ 新增:给每个收件人写一条(INSERT IGNORE 靠唯一键去重,见第 ④条)----
if (!$keep) {
return;
}
$ins = $db->prepare(
'INSERT IGNORE INTO notifications
(family_id, person_id, actor_id, kind, title, href, params,
target_type, target_id, is_read, created_at)
VALUES (?,?,?,?,?,?,?,?,?,0,NOW())'
);
$kindWord = $type === self::MOVIE ? '观影' : ($type === self::TRIP ? '旅程' : '记录');
$body = '有人把你加进了' . $kindWord . '「' . self::clip($title, 60) . '」';
foreach (array_keys($keep) as $pid) {
$ins->execute([
$familyId, (int)$pid, $actorId, $type, $body,
$href, $params ? json_encode($params, JSON_UNESCAPED_UNICODE) : null,
$type, $targetId,
]);
}
}
/**
* 宿主被删除时,把它的通知一并清掉。
* ⚠️ 不做外键(target_type 指向多张表,外键表达不了)→ 必须手动清,
* 否则通知点进去 404(本项目在 attachments 上就是这么做的,同一约定)。
*/
public static function removeForTarget(string $type, int $targetId): void
{
if ($targetId <= 0) {
return;
}
Database::get()->prepare(
'DELETE FROM notifications WHERE target_type = ? AND target_id = ?'
)->execute([$type, $targetId]);
}
/**
* 我的通知(最新在前)。
*
* ⚠️ 「我叫他什么」在**另一张表**(`nicknames`,按 viewer+target 存),
* `persons` 上**没有** `my_nickname` 这一列 —— 必须 LEFT JOIN 取。
* (本项目所有"显示某个人的称呼"的地方都是这么做的,见 Movie::attendees。)
* ⚠️ join 条件里要带**收件人**($personId)当 viewer:称呼是"我对他"的,
* 不是"他对我"的 —— 传错方向会让通知里出现别人叫他的名字。
*/
public static function mine(int $personId, int $limit = self::PAGE_SIZE, bool $unreadOnly = false): array
{
$sql = 'SELECT n.*, p.name AS actor_name, p.avatar AS actor_avatar,
nk.nickname AS actor_nick
FROM notifications n
LEFT JOIN persons p ON p.id = n.actor_id
LEFT JOIN nicknames nk ON nk.target_id = n.actor_id AND nk.viewer_id = ?
WHERE n.person_id = ?';
$params = [$personId, $personId];
if ($unreadOnly) {
$sql .= ' AND n.is_read = 0';
}
$sql .= ' ORDER BY n.id DESC LIMIT ' . max(1, min(200, $limit));
$st = Database::get()->prepare($sql);
$st->execute($params);
return $st->fetchAll();
}
/** 未读数(首页那块与头像下拉的角标都用它) */
public static function unreadCount(int $personId): int
{
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM notifications WHERE person_id = ? AND is_read = 0'
);
$st->execute([$personId]);
return (int)$st->fetchColumn();
}
/** 全部标记已读(进通知页就自动全读 —— 与"消息中心"的通用做法一致) */
public static function markAllRead(int $personId): int
{
$st = Database::get()->prepare(
'UPDATE notifications SET is_read = 1 WHERE person_id = ? AND is_read = 0'
);
$st->execute([$personId]);
return $st->rowCount();
}
/** 删除一条(只能删自己的 —— 与其它模块同一道越权栅栏) */
public static function delete(int $id, int $personId): bool
{
$st = Database::get()->prepare(
'DELETE FROM notifications WHERE id = ? AND person_id = ?'
);
$st->execute([$id, $personId]);
return $st->rowCount() > 0;
}
/** 清空我的(只删已读的?不 —— 用户点"清空"就是要清空,未读也清) */
public static function clearMine(int $personId): int
{
$st = Database::get()->prepare('DELETE FROM notifications WHERE person_id = ?');
$st->execute([$personId]);
return $st->rowCount();
}
/** 通知的跳转地址(params 是 json 存的,坏数据就当没有参数) */
public static function href(array $n): string
{
$params = [];
if (!empty($n['params'])) {
$dec = json_decode((string)$n['params'], true);
if (is_array($dec)) {
$params = $dec;
}
}
return url((string)$n['href'], $params);
}
/** 文案截断(多字节安全) */
private static function clip(string $s, int $max): string
{
$s = trim(preg_replace('/\s+/u', ' ', $s));
return $s === '' ? '(无标题)' : mb_substr($s, 0, $max, 'UTF-8');
}
}
+45 -1
View File
@@ -124,6 +124,14 @@ final class Schema
* ⚠️ 用一次性哨兵(`note_shares_migrated_v29`),理由同 v28 那次:
* 迁移是"一次性数据转换",不是"持续同步",
* 否则用户取消共享后下次启动又被灌回来(本项目已栽过 4 次)。
* 31 → **通知消息(用户 2026-10-08 第 5 条)**:新增 `notifications`。动机是用户的话:
* 「引入通知模块,这个模块,不在导航显示,在个人头像点击那里显示,
* 放在个人设置前面,名字:通知消息。就是当有人在观影、旅程等地方加我的时候,
* 发一个通知,通知出现在首页中,往年今天的前面。」
* ⇒ 通知**不进 Module::LIST**(进就会被顶栏渲染、被"模块显示与排序"当成开关),
* 它是一个独立页面(`?r=notifications`),入口只在头像下拉里。
* ⚠️ 唯一键 (person_id, target_type, target_id) 是整套设计的基石:
* 参与人是"整组替换",没有它,用户改三次标题就收到三条"加了你"。
*/
/**
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
@@ -131,7 +139,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/
public const VERSION = '30';
public const VERSION = '31';
/**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -1080,6 +1088,42 @@ CREATE TABLE IF NOT EXISTS `note_shares` (
CONSTRAINT `fk_ns_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='笔记共享给别人:可只读、可读写(v29:取代"共享给全家"那个布尔)'
SQL,
// ---- 通知消息(v30 第 5 条)----
//
// 用户原话:「引入通知模块,这个模块,不在导航显示,在个人头像点击那里显示,
// 放在个人设置前面,名字:通知消息。就是当有人在观影、旅程等地方
// 加我的时候,发一个通知,通知出现在首页中,往年今天的前面。」
//
// ⚠️ `target_type` + `target_id` **不做外键**(它指向 events 等多张表,
// 外键表达不了这个形状)—— 与 `attachments` 完全同一套做法与理由。
// 宿主删除时由 Notification::removeForTarget() 手动清,
// 否则通知点进去 404。
// ⚠️ 唯一键 `uk_ntf`(person + target_type + target_id)是**整套设计的基石**:
// 观影/旅程保存参与人是"整组替换"(先删再插),
// 没有它,用户改三次标题就会收到三条"有人把你加进了《…》"。
// Notification::syncForTarget() 靠 INSERT IGNORE 落在这条键上天然去重。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `notifications` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL COMMENT '收件人(**不含**动作发出者 —— 不给自己发)',
`actor_id` INT UNSIGNED DEFAULT NULL COMMENT '谁触发的(把别人加进去的人)',
`kind` VARCHAR(24) NOT NULL DEFAULT '' COMMENT 'movie / trip …(决定图标)',
`title` VARCHAR(255) NOT NULL COMMENT '一句话正文(已含宿主标题)',
`href` VARCHAR(64) DEFAULT NULL COMMENT '跳转路由(url() 的 r 值)',
`params` VARCHAR(255) DEFAULT NULL COMMENT '跳转参数(json,如 {"id":12})',
`target_type` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '宿主类型(去重维度之一)',
`target_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '宿主 id(去重维度之一)',
`is_read` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ntf` (`person_id`, `target_type`, `target_id`),
KEY `idx_ntf_unread` (`person_id`, `is_read`, `id`),
CONSTRAINT `fk_ntf_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_ntf_actor` FOREIGN KEY (`actor_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='通知消息(v30:别人在观影/旅程等地方把我加进去)'
SQL,
];
}
+39
View File
@@ -11,6 +11,45 @@ function e($value): string
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
/**
* 「多久之前」—— 通知列表用(v30 第 5 条)。
*
* ⚠️ 只做**粗粒度**(刚刚 / N 分钟前 / N 小时前 / 昨天 / N 天前 / 月-日),
* 不引 Carbon、也不做"1 个月前"这种会随月长变化的措辞 ——
* 通知的时间只是让人一眼看出"新不新",精确到秒反而不好读。
*/
function fb_time_ago(?string $dt): string
{
$s = trim((string)$dt);
if ($s === '') {
return '';
}
$t = strtotime($s);
if ($t === false) {
return '';
}
$d = time() - $t;
if ($d < 0) {
return date('n 月 j 日', $t); // 时间在未来(服务器时钟偏差)→ 直接给日期
}
if ($d < 60) {
return '刚刚';
}
if ($d < 3600) {
return intdiv($d, 60) . ' 分钟前';
}
if ($d < 86400) {
return intdiv($d, 3600) . ' 小时前';
}
if ($d < 172800) {
return '昨天';
}
if ($d < 86400 * 30) {
return intdiv($d, 86400) . ' 天前';
}
return date('n 月 j 日', $t);
}
/** 静态资源地址,带版本号(mtime + size)——没版本号的话浏览器会一直用旧缓存 */
function asset(string $rel): string
{