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:
@@ -1080,6 +1080,124 @@ final class Calendar
|
|||||||
return $out;
|
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] 里,逐天列出(含端点) */
|
/** 把 [start, end] 夹到 [from, to] 里,逐天列出(含端点) */
|
||||||
private static function eachDay(string $start, string $end, string $from, string $to): array
|
private static function eachDay(string $start, string $end, string $from, string $to): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -124,6 +124,14 @@ final class Schema
|
|||||||
* ⚠️ 用一次性哨兵(`note_shares_migrated_v29`),理由同 v28 那次:
|
* ⚠️ 用一次性哨兵(`note_shares_migrated_v29`),理由同 v28 那次:
|
||||||
* 迁移是"一次性数据转换",不是"持续同步",
|
* 迁移是"一次性数据转换",不是"持续同步",
|
||||||
* 否则用户取消共享后下次启动又被灌回来(本项目已栽过 4 次)。
|
* 否则用户取消共享后下次启动又被灌回来(本项目已栽过 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() 的说明。
|
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 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
|
CONSTRAINT `fk_ns_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
COMMENT='笔记共享给别人:可只读、可读写(v29:取代"共享给全家"那个布尔)'
|
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,
|
SQL,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,45 @@ function e($value): string
|
|||||||
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
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)——没版本号的话浏览器会一直用旧缓存 */
|
/** 静态资源地址,带版本号(mtime + size)——没版本号的话浏览器会一直用旧缓存 */
|
||||||
function asset(string $rel): string
|
function asset(string $rel): string
|
||||||
{
|
{
|
||||||
|
|||||||
+92
-10
@@ -3087,8 +3087,12 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
|||||||
.tg-col.is-today { background: #FDF6F1; }
|
.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 { display: block; height: 44px; border-top: 1px solid var(--line-2); box-sizing: border-box; }
|
||||||
.tg-line:first-child { border-top: 0; }
|
.tg-line:first-child { border-top: 0; }
|
||||||
|
/* ⚠️ v30 第 4 条:`left` 与 `width` 现在由**内联样式**给出(冲突分列后每条不同),
|
||||||
|
所以这里**不能**再写 `left/right` —— 绝对定位下 left+right+width 三者同时存在是
|
||||||
|
"过约束",浏览器会丢掉 `right`(看着像是碰巧对)。留一个兜底 left:2px,
|
||||||
|
万一某条没带内联 left(老数据/兜底路径)也不至于跑到列外去。 */
|
||||||
.tg-block {
|
.tg-block {
|
||||||
position: absolute; left: 2px; right: 2px;
|
position: absolute; left: 2px;
|
||||||
display: block; overflow: hidden; padding: 2px 5px;
|
display: block; overflow: hidden; padding: 2px 5px;
|
||||||
border-radius: 4px; color: #fff; font-size: 11px; line-height: 1.35;
|
border-radius: 4px; color: #fff; font-size: 11px; line-height: 1.35;
|
||||||
text-decoration: none; box-sizing: border-box;
|
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-ico { font-size: 22px; line-height: 1; font-weight: 300; }
|
||||||
.upbox-msg { font-size: 13.5px; }
|
.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) {
|
@media (max-width: 480px) {
|
||||||
.upbox { min-height: 78px; padding: 12px; }
|
.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);
|
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 {
|
.ag-ev {
|
||||||
position: absolute; left: 46px; right: 4px; display: flex; align-items: center; gap: 8px;
|
position: absolute; left: 46px; right: 4px;
|
||||||
padding: 5px 9px; min-height: 30px;
|
display: block; overflow: hidden; padding: 3px 7px;
|
||||||
background: var(--surface); border: 1px solid var(--line);
|
background: var(--brand); border-radius: 4px;
|
||||||
border-left: 3px solid var(--brand); border-radius: var(--radius-s);
|
font-size: 12px; line-height: 1.35; color: #fff;
|
||||||
box-shadow: var(--shadow); font-size: 13.5px; color: var(--ink);
|
text-decoration: none; box-sizing: border-box;
|
||||||
transform: translateY(2px);
|
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:hover { text-decoration: none; filter: brightness(1.08); z-index: 2; }
|
||||||
.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-t { display: block; font-weight: 600; font-variant-numeric: tabular-nums; margin-right: 4px; float: left; }
|
||||||
.ag-ev .ag-ev-w { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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; }
|
.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-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; }
|
.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%; }
|
||||||
|
}
|
||||||
|
|||||||
+55
-1
@@ -657,6 +657,60 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「选中文件后要有反应」—— v30 用户第 1 条。
|
||||||
|
*
|
||||||
|
* 用户原话:「导入日程,选中文件后,那个地方没有任何反应,虽然能导入,
|
||||||
|
* 但是体验不好,选中文件后,那个文件输入框,得有点反应才对。」
|
||||||
|
*
|
||||||
|
* 根因:**非暂存**的上传框(导入型:ics / 通讯录)在选中文件后**什么都不显示** ——
|
||||||
|
* 暂存型框靠 queueDrop() 画缩略图卡片,而导入型只是把 File 写进 `<input>`,
|
||||||
|
* 框里那句"点击或拖动进行上传"从头到尾不变,用户完全看不出"到底选上没有"。
|
||||||
|
* ⇒ 这里给所有"指向某个 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 的上传框/预览图",逐个挂上 */
|
/** 找"属于这个 input 的上传框/预览图",逐个挂上 */
|
||||||
function bindInput(input) {
|
function bindInput(input) {
|
||||||
bindZone(input, input);
|
bindZone(input, input);
|
||||||
@@ -675,7 +729,7 @@
|
|||||||
if (input.id) {
|
if (input.id) {
|
||||||
Array.prototype.forEach.call(
|
Array.prototype.forEach.call(
|
||||||
document.querySelectorAll('[data-sd-for="' + input.id + '"]'),
|
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); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ use App\NavFetch;
|
|||||||
use App\NodeType;
|
use App\NodeType;
|
||||||
use App\Nickname;
|
use App\Nickname;
|
||||||
use App\Note;
|
use App\Note;
|
||||||
|
use App\Notification;
|
||||||
use App\Person;
|
use App\Person;
|
||||||
use App\PersonGroup;
|
use App\PersonGroup;
|
||||||
use App\PersonJob;
|
use App\PersonJob;
|
||||||
@@ -497,8 +498,7 @@ function build_time_grid(array $events, array $cols): array
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$startMin = ((int)$m[1]) * 60 + (int)$m[2];
|
$startMin = ((int)$m[1]) * 60 + (int)$m[2];
|
||||||
// 结束时刻:有就按真实时长;没有就画一条 30 分钟的最短可见块
|
// 结束时刻:有就按真实时长;没有就先空着(下面还要看**下面那一条的起点**)。
|
||||||
// (高度是 0 等于看不见,那比"画得不准"更糟)。
|
|
||||||
$et = (string)($e['end_time'] ?? '');
|
$et = (string)($e['end_time'] ?? '');
|
||||||
$endMin = null;
|
$endMin = null;
|
||||||
if ($et !== '' && preg_match('/^(\d{2}):(\d{2})/', $et, $m2)) {
|
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;
|
$endMin = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$spanMin = $endMin === null ? 30 : max(15, $endMin - $startMin);
|
$timed[$ci][] = $e + ['_start' => $startMin, '_end' => $endMin];
|
||||||
// ⚠️ 上限到 24:00:超出的部分已经在网格外面,硬画出去会把整列撑长
|
}
|
||||||
$spanMin = min($spanMin, 1440 - $startMin);
|
|
||||||
$timed[$ci][] = $e + [
|
/* ==================== 冲突错开(v30 第 4 条)====================
|
||||||
'top' => round($startMin / 1440 * 100, 4),
|
用户原话:「如果两个事件时间上有冲突了,现在是堆叠在一起,应该错开,显示两个色块,
|
||||||
'height' => round($spanMin / 1440 * 100, 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];
|
return ['all_day' => $allDay, 'timed' => $timed];
|
||||||
}
|
}
|
||||||
@@ -867,6 +935,11 @@ switch ($r) {
|
|||||||
'today' => $today,
|
'today' => $today,
|
||||||
'homeModules'=> $homeMods,
|
'homeModules'=> $homeMods,
|
||||||
'moduleCards'=> $moduleCards,
|
'moduleCards'=> $moduleCards,
|
||||||
|
// v30 第 5 条:通知块(放在「往年的今天」之前,见 templates/home.php)
|
||||||
|
// ⚠️ 首页只给**最新的几条**(10 条)就够 —— 它是"瞥一眼"的地方,
|
||||||
|
// 真正的消息中心在 ?r=notifications。
|
||||||
|
'notifs' => Notification::mine($me, 10),
|
||||||
|
'notifUnread'=> Notification::unreadCount($me),
|
||||||
], 'home');
|
], 'home');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -1312,6 +1385,40 @@ switch ($r) {
|
|||||||
redirect('people');
|
redirect('people');
|
||||||
break;
|
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 就连名字都写不了。
|
// 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。
|
||||||
@@ -2671,11 +2778,20 @@ switch ($r) {
|
|||||||
|
|
||||||
if ($isEdit) {
|
if ($isEdit) {
|
||||||
Movie::update($familyId, $id, $d);
|
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();
|
clear_old();
|
||||||
flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
|
flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
|
||||||
redirect('movies/show', ['id' => $id]);
|
redirect('movies/show', ['id' => $id]);
|
||||||
} else {
|
} else {
|
||||||
$newId = Movie::create($familyId, $me, $d);
|
$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();
|
clear_old();
|
||||||
flash('ok', '已记下这场电影。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
|
flash('ok', '已记下这场电影。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
|
||||||
redirect('movies/show', ['id' => $newId]);
|
redirect('movies/show', ['id' => $newId]);
|
||||||
@@ -3230,11 +3346,19 @@ switch ($r) {
|
|||||||
}
|
}
|
||||||
if ($isEdit) {
|
if ($isEdit) {
|
||||||
Trip::update($familyId, $id, $d);
|
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();
|
clear_old();
|
||||||
flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
|
flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
|
||||||
redirect('trips/show', ['id' => $id]);
|
redirect('trips/show', ['id' => $id]);
|
||||||
}
|
}
|
||||||
$newId = Trip::create($familyId, (int)Auth::personId(), $d);
|
$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();
|
clear_old();
|
||||||
flash('ok', '这个旅程记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
|
flash('ok', '这个旅程记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
|
||||||
redirect('trips/show', ['id' => $newId]);
|
redirect('trips/show', ['id' => $newId]);
|
||||||
@@ -3579,6 +3703,16 @@ switch ($r) {
|
|||||||
}
|
}
|
||||||
$newId = Trip::saveNode($familyId, $tripId, $d, $isEdit ? $nid : null);
|
$newId = Trip::saveNode($familyId, $tripId, $d, $isEdit ? $nid : null);
|
||||||
$warns = attach_save_files($familyId, 'trip_node', $newId, 'photo', (int)Auth::personId());
|
$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 ? '这段行程已保存。' : '行程已加进来。')
|
flash('ok', ($isEdit ? '这段行程已保存。' : '行程已加进来。')
|
||||||
. ($warns ? '(' . implode(';', $warns) . ')' : ''));
|
. ($warns ? '(' . implode(';', $warns) . ')' : ''));
|
||||||
// ⚠️ 用户第 5 条:编辑是**独立页面**,保存后回详情页(而不是留在表单上)
|
// ⚠️ 用户第 5 条:编辑是**独立页面**,保存后回详情页(而不是留在表单上)
|
||||||
|
|||||||
@@ -303,9 +303,20 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
|||||||
<span class="tg-now" data-now-line style="top: <?= (float)$nowTop ?>%;"
|
<span class="tg-now" data-now-line style="top: <?= (float)$nowTop ?>%;"
|
||||||
title="现在 <?= e(date('H:i')) ?>"></span>
|
title="现在 <?= e(date('H:i')) ?>"></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php /* 🔴 v30 第 4 条:时间冲突的日程**错开画**(不再堆叠)。
|
||||||
|
用户原话:「如果两个事件时间上有冲突了,现在是堆叠在一起,应该错开,
|
||||||
|
显示两个色块,如果三个冲突了,就是三个色块,错开显示。」
|
||||||
|
left/width 由 App\Calendar::conflictLanes 算好(算法借鉴 SyncCalendar
|
||||||
|
的 TimelineUtil:贪心分列,列数 = 最大同时重叠数)。
|
||||||
|
⚠️ 用**内联 left/width 百分比**而不是 CSS 类:
|
||||||
|
列数 1/2/3/4 是运行时才知道的,类名会爆炸;
|
||||||
|
而且模板已经在内联 top/height 了,同一套坐标系最不容易错。 */ ?>
|
||||||
<?php foreach (($tgrid['timed'][$i] ?? []) as $ev): ?>
|
<?php foreach (($tgrid['timed'][$i] ?? []) as $ev): ?>
|
||||||
<a class="tg-block" data-cal="<?= (int)$ev['cal'] ?>"
|
<a class="tg-block" data-cal="<?= (int)$ev['cal'] ?>"
|
||||||
style="background: <?= e((string)$ev['color']) ?>; top: <?= (float)$ev['top'] ?>%; height: <?= (float)$ev['height'] ?>%;"
|
style="background: <?= e((string)$ev['color']) ?>;
|
||||||
|
top: <?= (float)$ev['top'] ?>%; height: <?= (float)$ev['height'] ?>%;
|
||||||
|
left: calc(2px + <?= (float)($ev['left'] ?? 0) ?>%);
|
||||||
|
width: calc(<?= (float)($ev['width'] ?? 100) ?>% - 4px);"
|
||||||
href="<?= e((string)$ev['href']) ?>"
|
href="<?= e((string)$ev['href']) ?>"
|
||||||
title="<?= e((string)$ev['title']) ?>">
|
title="<?= e((string)$ev['title']) ?>">
|
||||||
<b><?= e((string)$ev['time']) ?></b> <?= e((string)$ev['title']) ?>
|
<b><?= e((string)$ev['time']) ?></b> <?= e((string)$ev['title']) ?>
|
||||||
|
|||||||
+113
-9
@@ -227,6 +227,23 @@ if ($timed) {
|
|||||||
$nowH = (int)date('G');
|
$nowH = (int)date('G');
|
||||||
$nowPos = ($nowH - $startH) / max(1, ($endH - $startH)); // 红线位置(0~1)
|
$nowPos = ($nowH - $startH) / max(1, ($endH - $startH)); // 红线位置(0~1)
|
||||||
$showNow = $nowPos >= 0 && $nowPos <= 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">
|
<section class="section">
|
||||||
<div class="home-duo">
|
<div class="home-duo">
|
||||||
@@ -282,18 +299,48 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php /* 日程块:按"相对第一个刻度的小时数"定位(与日视图同一套比例)---- */ ?>
|
<?php /* 日程块:按"相对第一个刻度的小时数"定位(与日视图同一套比例)---- */ ?>
|
||||||
<?php foreach ($timed as $ev): ?>
|
<?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']);
|
$p = explode(':', (string)$ev['time']);
|
||||||
$evMin = ((int)($p[0] ?? 0)) * 60 + (int)($p[1] ?? 0);
|
$evMin = ((int)($p[0] ?? 0)) * 60 + ((int)($p[1] ?? 0));
|
||||||
$topPct = (($evMin / 60) - $startH) / max(1, ($endH - $startH)) * 100;
|
$topPct = (($evMin / 60) - $startH) / max(1, ($endH - $startH)) * 100;
|
||||||
$topPct = max(0, min(96, $topPct));
|
$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); // 至少能塞下一行
|
||||||
|
?>
|
||||||
<a class="ag-ev" href="<?= e((string)$ev['href']) ?>"
|
<a class="ag-ev" href="<?= e((string)$ev['href']) ?>"
|
||||||
style="top: <?= number_format($topPct, 2) ?>%; border-left-color: <?= e((string)$ev['color']) ?>;">
|
style="background: <?= e((string)$ev['color']) ?>;
|
||||||
|
top: <?= number_format($topPct, 2) ?>%;
|
||||||
|
height: <?= number_format($hPct, 2) ?>%;
|
||||||
|
left: calc(46px + <?= (float)($ev['left'] ?? 0) ?>%);
|
||||||
|
width: calc(<?= (float)($ev['width'] ?? 100) ?>% - 50px);"
|
||||||
|
title="<?= e((string)$ev['title']) ?>"
|
||||||
|
data-cal="<?= (int)$ev['cal'] ?>">
|
||||||
<span class="ag-ev-t"><?= e((string)$ev['time']) ?></span>
|
<span class="ag-ev-t"><?= e((string)$ev['time']) ?></span>
|
||||||
<span class="ag-ev-w"><?= e((string)$ev['title']) ?></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>
|
<span class="ag-ev-cal"><?= e((string)$ev['calname']) ?></span>
|
||||||
</a>
|
</a>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div><?php /* /.ag-grid */ ?>
|
</div><?php /* /.ag-grid */ ?>
|
||||||
@@ -321,14 +368,29 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
|
|||||||
<ul class="dash-list tk-list">
|
<ul class="dash-list tk-list">
|
||||||
<?php foreach ($tasks as $t): ?>
|
<?php foreach ($tasks as $t): ?>
|
||||||
<?php $late = \App\Task::isOverdue($t['due_at'] ?? null); ?>
|
<?php $late = \App\Task::isOverdue($t['due_at'] ?? null); ?>
|
||||||
<li class="dash-row tk-row" data-task-row="<?= (int)$t['id'] ?>">
|
<?php
|
||||||
|
/* 🔴 v30 第 3 条:「首页,近期任务,任务的名称和前面的点,都应该和主题颜色
|
||||||
|
一样,都保持一致。」
|
||||||
|
用户说的"主题颜色"= **任务模块的主题色**(个人设置里可换的那个)。
|
||||||
|
⚠️ 原来圆点用的是 `var(--brand)` —— 那是**当前页面**(首页)的主题色,
|
||||||
|
不是任务模块的;两者在用户改过模块配色时会不一样
|
||||||
|
(首页 = 砖红,任务可能是别的色),正是用户看到的不一致。
|
||||||
|
⇒ 两条都用同一个 `--tk-c`(= Module::color('tasks')),
|
||||||
|
与右上角「全部任务」按钮的 `--hd-c` 同源,全块一个色。
|
||||||
|
⚠️ 逾期仍用红色:那是状态语义(要显眼),不能被主题色盖掉;
|
||||||
|
但改名后的那个点也统一走 `--tk-c`(不逾期时)。 */
|
||||||
|
$tkColor = \App\Module::color('tasks', (int)\App\Auth::familyId());
|
||||||
|
?>
|
||||||
|
<li class="dash-row tk-row" data-task-row="<?= (int)$t['id'] ?>"
|
||||||
|
style="--tk-c: <?= e($tkColor) ?>;">
|
||||||
<?php
|
<?php
|
||||||
$tkId = (int)$t['id']; $tkStatus = (string)($t['status'] ?? 'todo');
|
$tkId = (int)$t['id']; $tkStatus = (string)($t['status'] ?? 'todo');
|
||||||
$tkBack = 'home'; $tkStatic = !\App\Auth::canWrite();
|
$tkBack = 'home'; $tkStatic = !\App\Auth::canWrite();
|
||||||
require __DIR__ . '/_task_check.php';
|
require __DIR__ . '/_task_check.php';
|
||||||
?>
|
?>
|
||||||
<span class="dash-dot" style="background: <?= e($late ? 'var(--red)' : 'var(--brand)') ?>;"></span>
|
<span class="dash-dot" style="background: <?= e($late ? 'var(--red)' : 'var(--tk-c)') ?>;"></span>
|
||||||
<a class="dash-what" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>">
|
<a class="dash-what" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>"
|
||||||
|
style="color: <?= e($late ? 'var(--red)' : $tkColor) ?>;">
|
||||||
<?= e((string)$t['title']) ?>
|
<?= e((string)$t['title']) ?>
|
||||||
</a>
|
</a>
|
||||||
<?php /* 用户第 3 条:「除了显示任务的标题之外,还要显示他的状态。
|
<?php /* 用户第 3 条:「除了显示任务的标题之外,还要显示他的状态。
|
||||||
@@ -364,6 +426,48 @@ $showNow = $nowPos >= 0 && $nowPos <= 1;
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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<?= empty($n['is_read']) ? ' is-unread' : '' ?>">
|
||||||
|
<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(preg_replace('/^有人/', '', (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 /* ==================== ③ 往年的今天 ==================== */ ?>
|
<?php /* ==================== ③ 往年的今天 ==================== */ ?>
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="page-head" style="margin-bottom:12px">
|
<div class="page-head" style="margin-bottom:12px">
|
||||||
|
|||||||
@@ -103,6 +103,20 @@ $isLoggedIn = \App\Auth::check();
|
|||||||
<i class="caret" aria-hidden="true"></i>
|
<i class="caret" aria-hidden="true"></i>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="menu-list">
|
<div class="menu-list">
|
||||||
|
<?php
|
||||||
|
/* 🔴 v30 第 5 条:「引入通知模块,这个模块,不在导航显示,在个人头像点击那里显示,
|
||||||
|
放在个人设置前面,名字:通知消息。」
|
||||||
|
⇒ 它排在**个人设置之前**,且带一个未读角标(有几条没看)。
|
||||||
|
⚠️ 这是通知唯一的入口 —— 顶栏不渲染它(它不在 Module::LIST,
|
||||||
|
见 app/Notification.php 头部的第 ①条)。 */
|
||||||
|
$fbUnread = \App\Notification::unreadCount((int)\App\Auth::personId());
|
||||||
|
?>
|
||||||
|
<a href="<?= e(url('notifications')) ?>">
|
||||||
|
通知消息
|
||||||
|
<?php if ($fbUnread > 0): ?>
|
||||||
|
<span class="menu-badge"><?= (int)$fbUnread ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</a>
|
||||||
<a href="<?= e(url('settings/me')) ?>">个人设置</a>
|
<a href="<?= e(url('settings/me')) ?>">个人设置</a>
|
||||||
<?php if (\App\Auth::isAdmin()): ?>
|
<?php if (\App\Auth::isAdmin()): ?>
|
||||||
<a href="<?= e(url('settings')) ?>">系统设置</a>
|
<a href="<?= e(url('settings')) ?>">系统设置</a>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 通知消息(v30 第 5 条)。
|
||||||
|
*
|
||||||
|
* 用户原话:「引入通知模块,这个模块,不在导航显示,在个人头像点击那里显示,
|
||||||
|
* 放在个人设置前面,名字:通知消息。就是当有人在观影、旅程等地方
|
||||||
|
* 加我的时候,发一个通知。」
|
||||||
|
*
|
||||||
|
* 所以这一页**只有一条线**:谁、在什么时候、把"我"加进了哪件事 —— 点进去就是那件事。
|
||||||
|
*
|
||||||
|
* ⚠️ 进页面即全部标已读(在路由里做的)—— 消息中心的惯例:
|
||||||
|
* 打开看看就等于"看过了",不需要再让用户手动一条条点"已读"。
|
||||||
|
* 但**未读的才会被"撤销"**(被移出参与人时):已读的是历史,不删(见 Notification 头部)。
|
||||||
|
*
|
||||||
|
* @param array $items 我的通知(最新在前,含 actor_name / actor_nick / actor_avatar)
|
||||||
|
*/
|
||||||
|
$items = $items ?? [];
|
||||||
|
?>
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>通知消息</h1>
|
||||||
|
<span class="sub">共 <?= count($items) ?> 条</span>
|
||||||
|
<div class="actions">
|
||||||
|
<?php if ($items): ?>
|
||||||
|
<form method="post" action="<?= e(url('notifications/clear')) ?>"
|
||||||
|
onsubmit="return confirm('清空全部通知?清掉就找不回来了。')" style="display:inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">清空</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a class="btn btn-ghost btn-sm" href="<?= e(url('home')) ?>">返回首页</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<?php if (!$items): ?>
|
||||||
|
<p class="empty-hint">
|
||||||
|
还没有通知。别人在观影、旅程里把你加进去时,这里就会有消息。
|
||||||
|
</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="ntf-list">
|
||||||
|
<?php foreach ($items as $n): ?>
|
||||||
|
<?php
|
||||||
|
// 谁发起的:优先用"我叫他什么"(与全站称呼口径一致)
|
||||||
|
$actorName = \App\Nickname::display(
|
||||||
|
(int)\App\Auth::personId(),
|
||||||
|
['name' => $n['actor_name'] ?? '', 'my_nickname' => $n['actor_nick'] ?? '']
|
||||||
|
);
|
||||||
|
if (trim((string)$actorName) === '') { $actorName = '有人'; }
|
||||||
|
$isUnread = empty($n['is_read']);
|
||||||
|
// 图标:按 kind 分(观影 = 胶片,旅程 = 脚印,其余 = 铃铛)
|
||||||
|
$kind = (string)($n['kind'] ?? '');
|
||||||
|
?>
|
||||||
|
<li class="ntf-row<?= $isUnread ? ' is-unread' : '' ?>">
|
||||||
|
<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(preg_replace('/^有人/', '', (string)$n['title'])) ?>
|
||||||
|
</span>
|
||||||
|
<span class="ntf-time"><?= e(fb_time_ago((string)$n['created_at'])) ?></span>
|
||||||
|
</a>
|
||||||
|
<span class="ntf-kind ntf-kind-<?= e($kind) ?>" aria-hidden="true">
|
||||||
|
<?= $kind === 'movie' ? '🎬' : (strpos($kind, 'trip') === 0 ? '🧭' : '🔔') ?>
|
||||||
|
</span>
|
||||||
|
<form method="post" action="<?= e(url('notifications/del')) ?>" class="ntf-del">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit" title="删除这条">删除</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user