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:
@@ -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 条:编辑是**独立页面**,保存后回详情页(而不是留在表单上)
|
||||
|
||||
Reference in New Issue
Block a user