From 2a10df2557feeae49dc123a6d467915b2f088da3 Mon Sep 17 00:00:00 2001 From: laoyang Date: Thu, 8 Oct 2026 17:41:30 +0800 Subject: [PATCH] =?UTF-8?q?v30=20=E5=90=8E=E5=8D=8A=EF=BC=885=20=E6=9D=A1?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E5=AF=BC=E5=85=A5=E6=A1=86=E9=80=89=E4=B8=AD?= =?UTF-8?q?=E5=8F=8D=E9=A6=88+=E5=8D=95=E9=80=89=20/=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E6=97=A5=E7=A8=8B=E6=94=B9=E8=89=B2=E5=9D=97=20/=20=E8=BF=91?= =?UTF-8?q?=E6=9C=9F=E4=BB=BB=E5=8A=A1=E4=B8=BB=E9=A2=98=E8=89=B2=20/=20?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E5=86=B2=E7=AA=81=E9=94=99=E5=BC=80=20/=20?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E6=B6=88=E6=81=AF=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 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 已升级。 --- app/Calendar.php | 118 +++++++++++++++++ app/Notification.php | 213 ++++++++++++++++++++++++++++++ app/Schema.php | 46 ++++++- app/helpers.php | 39 ++++++ assets/css/app.css | 102 ++++++++++++-- assets/js/app.js | 56 +++++++- index.php | 152 +++++++++++++++++++-- templates/calendar/index.php | 13 +- templates/home.php | 122 +++++++++++++++-- templates/layout.php | 14 ++ templates/notifications/index.php | 74 +++++++++++ 11 files changed, 918 insertions(+), 31 deletions(-) create mode 100644 app/Notification.php create mode 100644 templates/notifications/index.php diff --git a/app/Calendar.php b/app/Calendar.php index 7aac0c9..c8baf81 100644 --- a/app/Calendar.php +++ b/app/Calendar.php @@ -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 { diff --git a/app/Notification.php b/app/Notification.php new file mode 100644 index 0000000..06aca65 --- /dev/null +++ b/app/Notification.php @@ -0,0 +1,213 @@ +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'); + } +} \ No newline at end of file diff --git a/app/Schema.php b/app/Schema.php index e55e1fa..88c7e93 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -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, ]; } diff --git a/app/helpers.php b/app/helpers.php index 2de5487..02bf3c7 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -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 { diff --git a/assets/css/app.css b/assets/css/app.css index 7494d69..e727b29 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -3087,8 +3087,12 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, .tg-col.is-today { background: #FDF6F1; } .tg-line { display: block; height: 44px; border-top: 1px solid var(--line-2); box-sizing: border-box; } .tg-line:first-child { border-top: 0; } +/* ⚠️ v30 第 4 条:`left` 与 `width` 现在由**内联样式**给出(冲突分列后每条不同), + 所以这里**不能**再写 `left/right` —— 绝对定位下 left+right+width 三者同时存在是 + "过约束",浏览器会丢掉 `right`(看着像是碰巧对)。留一个兜底 left:2px, + 万一某条没带内联 left(老数据/兜底路径)也不至于跑到列外去。 */ .tg-block { - position: absolute; left: 2px; right: 2px; + position: absolute; left: 2px; display: block; overflow: hidden; padding: 2px 5px; border-radius: 4px; color: #fff; font-size: 11px; line-height: 1.35; text-decoration: none; box-sizing: border-box; @@ -3902,6 +3906,18 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b } .upbox-ico { font-size: 22px; line-height: 1; font-weight: 300; } .upbox-msg { font-size: 13.5px; } +/* 🔴 v30 第 1 条:「选中文件后,那个文件输入框,得有点反应才对。」 + 非暂存框(导入型)选中文件后由 app.js 的 bindFileEcho() 加 `.is-picked` + 并把框里的话换成文件名 —— 这里把它从"虚线待放"变成"实线已选", + 配色也换成品牌色的浅底,一眼能看出"这一步已经做完了"。 + ⚠️ 与 `.is-over`(拖动经过的高亮)区分开:那个是**临时**高亮, + 这个是**已选**的稳定态,两者同时命中时以 is-picked 为准(写在后面)。 */ +.upbox.is-picked { + border-style: solid; border-color: var(--brand); + background: #FDF3EC; color: var(--brand-ink, var(--brand)); +} +.upbox.is-picked .upbox-ico { font-size: 18px; font-weight: 600; } +.upbox.is-picked .upbox-msg { font-weight: 600; word-break: break-all; } /* 窄屏:框再矮一点,别把表单撑太长 */ @media (max-width: 480px) { .upbox { min-height: 78px; padding: 12px; } @@ -4260,17 +4276,28 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b border-radius: 50%; background: var(--red); } /* 日程块 */ +/* 🔴 v30 第 2 条:「首页上的今天的日程,用色块表示,和日程保持一致。」 + ⇒ 从原来的"白底卡片 + 左边 3px 色线"改成**与日历页 `.tg-block` 同款的实心色块**: + 底色 = 日历本色(内联给)、白字、圆角、无边框阴影。 + ⚠️ `left` 与 `width` 由**内联样式**给出(冲突分列的结果,见 templates/home.php)—— + 这里不再写 left/right(绝对定位下 left+right+width 三者同时存在是过约束)。 + ⚠️ `transform: translateY(2px)` 那点微调去掉了:色块要精确落在刻度上, + 偏移 2px 会让"9:00 的块"看起来不在 9:00 那条线上。 + ⚠️ 白字压浅色本的兜底:加一层极轻的 text-shadow(不加深色罩,保持本色)。 */ .ag-ev { - position: absolute; left: 46px; right: 4px; display: flex; align-items: center; gap: 8px; - padding: 5px 9px; min-height: 30px; - background: var(--surface); border: 1px solid var(--line); - border-left: 3px solid var(--brand); border-radius: var(--radius-s); - box-shadow: var(--shadow); font-size: 13.5px; color: var(--ink); - transform: translateY(2px); + position: absolute; left: 46px; right: 4px; + display: block; overflow: hidden; padding: 3px 7px; + background: var(--brand); border-radius: 4px; + font-size: 12px; line-height: 1.35; color: #fff; + text-decoration: none; box-sizing: border-box; + border: 1px solid rgba(255,255,255,.35); + text-shadow: 0 1px 1px rgba(0,0,0,.18); } -.ag-ev:hover { box-shadow: var(--shadow-l); } -.ag-ev .ag-ev-t { flex: 0 0 auto; font-size: 11.5px; color: var(--muted); font-variant-numeric: tabular-nums; } -.ag-ev .ag-ev-w { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ag-ev:hover { text-decoration: none; filter: brightness(1.08); z-index: 2; } +.ag-ev .ag-ev-t { display: block; font-weight: 600; font-variant-numeric: tabular-nums; margin-right: 4px; float: left; } +.ag-ev .ag-ev-w { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; } +/* 日历本名:色块右下角的极淡小字(色块面积小,不加大徽标) */ +.ag-ev .ag-ev-cal { display: block; font-size: 10.5px; opacity: .82; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* ---- 任务行:标题 + 状态 + **看板名** + 截止 ---- */ .tk-list { display: flex; flex-direction: column; } @@ -4983,3 +5010,58 @@ h5.md-h, h6.md-h { font-size: 14px; } .note-img-size.is-on { border-color: var(--brand); background: var(--brand-soft, #FDF3EC); color: var(--brand-ink); } /* 图上标着当前档位,编辑器里一眼能看出"这张被缩过了" */ .note-block[data-kind="image"] .note-block-img { transition: width .12s ease; } + +/* ============================================================ + 通知消息(v30 第 5 条) + ------------------------------------------------------------ + 用户原话:「引入通知模块,这个模块,不在导航显示,在个人头像点击那里显示, + 放在个人设置前面,名字:通知消息。」 + + ⚠️ 三处共用这一套样式:头像下拉里的入口、?r=notifications 页、首页那一块。 + 分开写三遍迟早长歪(本项目在"设置下拉"上刚吃过这个亏)。 + ⚠️ 未读用**左侧一条品牌色竖线 + 浅底**表达,不用加粗字重 —— + 字重变化会让整行宽度轻微跳动,一列通知看下来眼睛累。 + ============================================================ */ +/* 头像下拉里的入口:文案 + 未读角标(角标推到最右) */ +.menu-list a:has(.menu-badge), +.menu-list a .menu-badge { + display: inline-flex; align-items: center; +} +.menu-list a .menu-badge { + margin-left: auto; min-width: 18px; height: 18px; padding: 0 5px; + border-radius: 999px; background: var(--red); color: #fff; + font-size: 11px; line-height: 18px; justify-content: center; font-weight: 600; +} + +.ntf-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } +.ntf-row { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; border-radius: var(--radius-s); + border-left: 3px solid transparent; +} +.ntf-row:hover { background: var(--line-2); } +/* 未读:左侧品牌色竖线 + 极浅底(见上面的说明:不用字重) */ +.ntf-row.is-unread { border-left-color: var(--brand); background: var(--brand-soft, #FDF6F1); } +.ntf-av { + flex: 0 0 auto; width: 30px; height: 30px; border-radius: 50%; + object-fit: cover; background: var(--line-2); +} +.ntf-main { + flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 10px; + color: var(--ink); text-decoration: none; +} +.ntf-main:hover { text-decoration: none; color: var(--brand); } +.ntf-title { flex: 1 1 auto; min-width: 0; font-size: 14px; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } +.ntf-title b { font-weight: 600; margin-right: 2px; } +.ntf-time { flex: 0 0 auto; font-size: 12px; color: var(--faint); font-variant-numeric: tabular-nums; } +.ntf-kind { flex: 0 0 auto; font-size: 15px; line-height: 1; } +.ntf-del { flex: 0 0 auto; } +/* 首页那块:行距紧一点(它只是"瞥一眼",不是消息中心) */ +.ntf-list-home .ntf-row { padding: 6px 8px; } + +@media (max-width: 640px) { + /* 窄屏把时间挪到下一行,别把标题挤成两三个字 */ + .ntf-main { flex-wrap: wrap; gap: 2px 10px; } + .ntf-title { flex: 1 1 100%; } +} diff --git a/assets/js/app.js b/assets/js/app.js index 806ac82..a4ecfbd 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -657,6 +657,60 @@ }); } + /** + * 「选中文件后要有反应」—— v30 用户第 1 条。 + * + * 用户原话:「导入日程,选中文件后,那个地方没有任何反应,虽然能导入, + * 但是体验不好,选中文件后,那个文件输入框,得有点反应才对。」 + * + * 根因:**非暂存**的上传框(导入型:ics / 通讯录)在选中文件后**什么都不显示** —— + * 暂存型框靠 queueDrop() 画缩略图卡片,而导入型只是把 File 写进 ``, + * 框里那句"点击或拖动进行上传"从头到尾不变,用户完全看不出"到底选上没有"。 + * ⇒ 这里给所有"指向某个 file input 的框"补一条 change 监听: + * 选到文件就把框里的话换成**文件名**(+ 可点"重选"),选不到(取消)就还原。 + * + * ⚠️ 只认 `data-sd-for` 指向的那些框(片段自己声明的),不碰别的上传区 —— + * 暂存型框有自己的清单 UI,这里插一脚会和它打架。 + * ⚠️ 用**原始**文案做还原基准(`data-sd-msg`):用户点了重选又取消时, + * 框里那句话必须回到原文,不能变成空字符串。 + */ + function bindFileEcho(box, input) { + if (!box || !input || box.getAttribute('data-sd-echo') === '1') { return; } + // ⚠️ **只给非暂存框**挂(暂存型框由 queueDrop 画缩略图卡片,别去抢它的 UI)。 + // 暂存型框带 data-drop-stage;导入型不带(见 _stage_drop.php 头部的 v30 说明)。 + if (box.hasAttribute('data-drop-stage')) { return; } + box.setAttribute('data-sd-echo', '1'); + // 记下原始文案(片段渲染的那句「点击或拖动进行上传」或 $sdMsg) + if (!box.hasAttribute('data-sd-msg')) { + var msgEl = box.querySelector('.upbox-msg'); + box.setAttribute('data-sd-msg', msgEl ? msgEl.textContent : ''); + } + var msgEl2 = box.querySelector('.upbox-msg'); + var icoEl = box.querySelector('.upbox-ico'); + function paint() { + var f = input.files && input.files[0]; + var list = input.files ? Array.prototype.slice.call(input.files) : []; + if (!list.length) { + // 没选(或用户取消了)→ 还原成原始提示 + box.classList.remove('is-picked'); + if (msgEl2) { msgEl2.textContent = box.getAttribute('data-sd-msg'); } + if (icoEl) { icoEl.textContent = '+'; } + return; + } + box.classList.add('is-picked'); + if (icoEl) { icoEl.textContent = '✓'; } + if (msgEl2) { + // 单选直接给文件名;多选给"第一个 + 等 N 个" + msgEl2.textContent = list.length === 1 + ? ('已选择:' + (f.name || '文件') + '(点这里可重选)') + : ('已选择 ' + list.length + ' 个文件:' + (f.name || '') + ' 等(点这里可重选)'); + } + } + input.addEventListener('change', paint); + // 拖进来的文件也会派发 change(见 bindZone),所以这一条同时覆盖"点选"和"拖入" + paint(); + } + /** 找"属于这个 input 的上传框/预览图",逐个挂上 */ function bindInput(input) { bindZone(input, input); @@ -675,7 +729,7 @@ if (input.id) { Array.prototype.forEach.call( document.querySelectorAll('[data-sd-for="' + input.id + '"]'), - function (bx) { bindZone(bx, input); bindBox(bx, input); }); + function (bx) { bindZone(bx, input); bindBox(bx, input); bindFileEcho(bx, input); }); } } diff --git a/index.php b/index.php index d07f688..2372c13 100644 --- a/index.php +++ b/index.php @@ -45,6 +45,7 @@ use App\NavFetch; use App\NodeType; use App\Nickname; use App\Note; +use App\Notification; use App\Person; use App\PersonGroup; use App\PersonJob; @@ -497,8 +498,7 @@ function build_time_grid(array $events, array $cols): array continue; } $startMin = ((int)$m[1]) * 60 + (int)$m[2]; - // 结束时刻:有就按真实时长;没有就画一条 30 分钟的最短可见块 - // (高度是 0 等于看不见,那比"画得不准"更糟)。 + // 结束时刻:有就按真实时长;没有就先空着(下面还要看**下面那一条的起点**)。 $et = (string)($e['end_time'] ?? ''); $endMin = null; if ($et !== '' && preg_match('/^(\d{2}):(\d{2})/', $et, $m2)) { @@ -508,13 +508,81 @@ function build_time_grid(array $events, array $cols): array $endMin = null; } } - $spanMin = $endMin === null ? 30 : max(15, $endMin - $startMin); - // ⚠️ 上限到 24:00:超出的部分已经在网格外面,硬画出去会把整列撑长 - $spanMin = min($spanMin, 1440 - $startMin); - $timed[$ci][] = $e + [ - 'top' => round($startMin / 1440 * 100, 4), - 'height' => round($spanMin / 1440 * 100, 4), - ]; + $timed[$ci][] = $e + ['_start' => $startMin, '_end' => $endMin]; + } + + /* ==================== 冲突错开(v30 第 4 条)==================== + 用户原话:「如果两个事件时间上有冲突了,现在是堆叠在一起,应该错开,显示两个色块, + 如果三个冲突了,就是三个色块,错开显示。」 + 算法借鉴 SyncCalendar 的 TimelineUtil(见 App\Calendar::conflictLanes)。 + + ⚠️ 高度要在**分列之后**才算: + ① "没填结束时间"的日程不能一律画 30 分钟 —— 那会让它和后面所有日程都"冲突" + (一个 09:00 没结束的日程配一个 09:30 的日程,就假冲突成两列)。 + ⇒ 优先拿**同一列里下一条的起点**当它的结束(那正是"视觉上到哪儿为止"); + 没有下一条时才退到 30 分钟的最短可见块。 + ② 分列用的"结束分钟"必须**按列内顺序**算,所以必须先分列、再算高度。 + 这里用一个中间量 `_effEnd` 把"实际显示到几点"带回给模板。 */ + foreach ($timed as $ci => $list) { + if (!$list) { + continue; + } + // ① 先按"声明的结束"排一次序,好让同列的下一条能被看到(同一列内互不重叠) + $sorted = $list; + usort($sorted, static function (array $a, array $b): int { + return $a['_start'] <=> $b['_start']; + }); + // ② 分列:暂时用"声明结束;没有则 +30'",只为拿到 lane(宽度/左偏移) + $probe = array_map(static function (array $e): array { + return [ + 'time' => sprintf('%02d:%02d', intdiv($e['_start'], 60), $e['_start'] % 60), + 'end_time' => $e['_end'] === null + ? '' : sprintf('%02d:%02d', intdiv($e['_end'], 60), $e['_end'] % 60), + ]; + }, $sorted); + $lanes = \App\Calendar::conflictLanes($probe); + + // ③ 逐条算"实际显示结束":优先同列下一条的起点 + $out = []; + $laneLastEnd = []; // 每列当前已排到哪儿(分钟) + foreach ($sorted as $k => $e) { + [$left, $width] = $lanes[$k] ?? [0.0, 1.0]; + $lane = $width > 0 ? (int)round($left / $width) : 0; + $s = $e['_start']; + $en = $e['_end']; + if ($en === null) { + // 同列下一条(时间上紧接着的那条)的起点,就是它"视觉上"的结束 + $next = null; + foreach ($sorted as $k2 => $e2) { + if ($k2 <= $k) { continue; } + $l2 = $lanes[$k2] ?? [0.0, 1.0]; + $lane2 = $l2[1] > 0 ? (int)round($l2[0] / $l2[1]) : 0; + if ($lane2 === $lane && $e2['_start'] > $s) { $next = $e2['_start']; break; } + } + $en = $next !== null ? $next : $s + 30; + } + $spanMin = max(15, $en - $s); + // ⚠️ 上限到 24:00:超出的部分已经在网格外面,硬画出去会把整列撑长 + $spanMin = min($spanMin, 1440 - $s); + $out[$k] = [ + 'top' => round($s / 1440 * 100, 4), + 'height' => round($spanMin / 1440 * 100, 4), + 'left' => round($left * 100, 4), + 'width' => round($width * 100, 4), + ]; + } + // ⚠️ `$out` 的键必须与 `$sorted` 对齐(上面按 $k 写入),**不能**在这里 + // usort 它 —— usort 会重排下标,随后 `$out[$k]` 就取到别人的分列结果了。 + // 要排序请在拼好 `$clean` 之后排。 + $clean = []; + foreach ($sorted as $k => $e) { + unset($e['_start'], $e['_end']); + $clean[] = $e + ($out[$k] ?? ['top' => 0, 'height' => 0, 'left' => 0, 'width' => 100]); + } + usort($clean, static function (array $a, array $b): int { + return $a['top'] <=> $b['top']; + }); + $timed[$ci] = $clean; } return ['all_day' => $allDay, 'timed' => $timed]; } @@ -867,6 +935,11 @@ switch ($r) { 'today' => $today, 'homeModules'=> $homeMods, 'moduleCards'=> $moduleCards, + // v30 第 5 条:通知块(放在「往年的今天」之前,见 templates/home.php) + // ⚠️ 首页只给**最新的几条**(10 条)就够 —— 它是"瞥一眼"的地方, + // 真正的消息中心在 ?r=notifications。 + 'notifs' => Notification::mine($me, 10), + 'notifUnread'=> Notification::unreadCount($me), ], 'home'); break; @@ -1312,6 +1385,40 @@ switch ($r) { redirect('people'); break; + // ==================== 通知消息(v30 第 5 条)==================== + // 用户原话:「引入通知模块,这个模块,不在导航显示,在个人头像点击那里显示, + // 放在个人设置前面,名字:通知消息。」 + // ⚠️ 它**不在 Module::LIST** 里(见 app/Notification.php 头部的第 ①条)—— + // 所以顶栏不渲染它、个人设置的"模块显示与排序"里也没有它。 + case 'notifications': + Auth::requireLogin(); + $me = (int)Auth::personId(); + + // 打开这一页 = 都看过了。放在**取列表之前**会把刚进来的那批也标成已读, + // 但列表照旧照常显示(只是左侧那个"未读"蓝点没了)—— 与消息中心的惯例一致。 + Notification::markAllRead($me); + View::render('notifications/index', [ + 'title' => '通知消息', + 'items' => Notification::mine($me), + ], 'notifications'); + break; + + case 'notifications/del': + Auth::requireLogin(); + csrf_check(); + Notification::delete((int)($_POST['id'] ?? 0), (int)Auth::personId()); + flash('ok', '这条通知已删除。'); + redirect('notifications'); + break; + + case 'notifications/clear': + Auth::requireLogin(); + csrf_check(); + $n = Notification::clearMine((int)Auth::personId()); + flash('ok', $n > 0 ? ('已清空 ' . $n . ' 条通知。') : '本来就没有通知。'); + redirect('notifications'); + break; + // ==================== 我自己的资料 / 个人设置 ==================== // 顶栏头像点开 →「个人设置」。访客也能改自己的资料 —— // 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。 @@ -2671,11 +2778,20 @@ switch ($r) { if ($isEdit) { Movie::update($familyId, $id, $d); + // v30 第 5 条:把"这次把谁加进来了"变成通知(撤销逻辑见 Notification::syncForTarget) + Notification::syncForTarget( + $familyId, Notification::MOVIE, $id, (string)$d['title'], $me, + (array)($d['attendees'] ?? []), 'movies/show', ['id' => $id] + ); clear_old(); flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); redirect('movies/show', ['id' => $id]); } else { $newId = Movie::create($familyId, $me, $d); + Notification::syncForTarget( + $familyId, Notification::MOVIE, $newId, (string)$d['title'], $me, + (array)($d['attendees'] ?? []), 'movies/show', ['id' => $newId] + ); clear_old(); flash('ok', '已记下这场电影。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); redirect('movies/show', ['id' => $newId]); @@ -3230,11 +3346,19 @@ switch ($r) { } if ($isEdit) { Trip::update($familyId, $id, $d); + Notification::syncForTarget( + $familyId, Notification::TRIP, $id, (string)$d['title'], (int)Auth::personId(), + (array)($d['attendees'] ?? []), 'trips/show', ['id' => $id] + ); clear_old(); flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); redirect('trips/show', ['id' => $id]); } $newId = Trip::create($familyId, (int)Auth::personId(), $d); + Notification::syncForTarget( + $familyId, Notification::TRIP, $newId, (string)$d['title'], (int)Auth::personId(), + (array)($d['attendees'] ?? []), 'trips/show', ['id' => $newId] + ); clear_old(); flash('ok', '这个旅程记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); redirect('trips/show', ['id' => $newId]); @@ -3579,6 +3703,16 @@ switch ($r) { } $newId = Trip::saveNode($familyId, $tripId, $d, $isEdit ? $nid : null); $warns = attach_save_files($familyId, 'trip_node', $newId, 'photo', (int)Auth::personId()); + /* 🔴 v30 第 5 条:**这里才是旅程里"把谁加进来"的日常入口** + (活动级那个"同行的人"字段 v24 之后就没了,参与人从行程长出来)。 + ⇒ 通知挂在**这一段行程**上(target_type='trip_node'),点进去直接落到那一段。 + ⚠️ 用 `members` 而不是 `attendees`:表单字段名就是它(见上面 $d 的装配)。 */ + Notification::syncForTarget( + $familyId, 'trip_node', $newId, + (string)$d['title'] . '(' . (string)($t['title'] ?? '旅程') . ')', + (int)Auth::personId(), (array)($d['members'] ?? []), + 'trips/show', ['id' => $tripId] + ); flash('ok', ($isEdit ? '这段行程已保存。' : '行程已加进来。') . ($warns ? '(' . implode(';', $warns) . ')' : '')); // ⚠️ 用户第 5 条:编辑是**独立页面**,保存后回详情页(而不是留在表单上) diff --git a/templates/calendar/index.php b/templates/calendar/index.php index 1c809d3..cae1927 100644 --- a/templates/calendar/index.php +++ b/templates/calendar/index.php @@ -303,9 +303,20 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts) + diff --git a/templates/home.php b/templates/home.php index b720527..2bee695 100644 --- a/templates/home.php +++ b/templates/home.php @@ -227,6 +227,23 @@ if ($timed) { $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); ?>
@@ -282,18 +299,48 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
- - + // 结束时刻:缺省时按"下一件事的起点"或 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); // 至少能塞下一行 + ?>
+ style="background: ; + top: %; + height: %; + left: calc(46px + %); + width: calc(% - 50px);" + title="" + data-cal=""> - + @@ -321,14 +368,29 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
+ + +
+
+

+ 通知消息 + 0): ?> 条未读 +

+ +
+
+
    + + $n['actor_name'] ?? '', 'my_nickname' => $n['actor_nick'] ?? ''] + ); + if (trim((string)$actorName) === '') { $actorName = '有人'; } + ?> +
  • + + + + + + + +
  • + +
+
+
+ +
diff --git a/templates/layout.php b/templates/layout.php index 3b3d802..eb38206 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -103,6 +103,20 @@ $isLoggedIn = \App\Auth::check();