v32 第 32 批(4 条):资源共享弹框真落库 / 消息通知覆盖全部共享入口 / 浏览器通知(站内消息+日程到期提醒) / 首页任务随看板色
用户原话: ①「资源共享功能有问题,弹框中,点击共享给某个人后,弹框消失,但是并没有共享成功。」 ②「消息通知,在所有引用自己的地方,都通知,包括别人分享了日历本给自己, 分享了笔记给自己,分享了资源给自己等等。」 ③「当有消息通知时,还有日历中日程到期提醒时,比如一个日程,设置了10点开始, 提前十分钟提醒,则需要在 9:50 时浏览器中发提醒通知。」 ④「首页,近期任务,任务的文字和前面的圆点,颜色并没有跟看板颜色一致,这个需要修正。」 四条真根因: ① v28 把资源编辑改成三页签后,每个页签各有自己的 form 与 <input name=id>, 而设 id 的代码是 panel.querySelector() → 只命中文档序第一个(info 的 update), ⇒ share/att 表单的 id 恒空 ⇒ 提交 id=0 ⇒ Resource::find(familyId,0,me) 返回 null ⇒ 静默不共享。修:表单打 data-res-idform 标记 + querySelectorAll 逐个填。 ② v30 只在 saveAttendees 后调 syncForTarget,6 个共享入口一个都没接。 新增 Notification::syncShares(家庭,类型,宿主id,动作人) —— 照共享表现状重建差额 (写表后调用;漏调只会没通知,不会状态不一致)。SHARE_KINDS 抽常量。 ③ 完全没有浏览器通知。新增 Calendar::remindersBetween()(窗口留 -2 分钟防后台节流) + ?r=notifications/poll JSON 路由 + assets/js/notify.js(轮询 + Notification API + id@at 去重)+ 顶栏铃铛(权限只能用户点一下才要,自动申请会被 Chrome 静默拒绝)。 ④ v30 把用户那句和主题颜色一样理解成了**任务模块主题色**,用户要的是**看板色**。 新增 Task::boardColorById(家庭,看板id)(没设色按看板名派生稳定色),home.php 两处改 $tkColor。 验证:诊断探针 18/0、真机 25/0(真点共享→库里真的多一行;三类共享真发通知; 9:50 窗口真算出 10:00 那条;首页每条任务色 == 其看板色)、反向 5/0、 e2e 1502/0、升级 153/0、几何 428+680+92、城市 32/0。 部署脚本静态断言段本地空跑通过;线上站点自检 19 条全过,notifications/poll 未登录 302。
This commit is contained in:
+86
-1
@@ -728,8 +728,93 @@ final class Calendar
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 任意形态的提醒值 → **合法分钟数数组**(升序、去重)。
|
* v32 第 3 条:**到点的提醒**(浏览器通知用)。
|
||||||
*
|
*
|
||||||
|
* 用户原话:「还有日历中日程到期提醒时,比如一个日程,设置了10点开始,
|
||||||
|
* 提前十分钟提醒,则需要在9:50时浏览器中发提醒通知。」
|
||||||
|
*
|
||||||
|
* @param string $from 窗口起点(含),'Y-m-d H:i:s'
|
||||||
|
* @param string $to 窗口终点(含)
|
||||||
|
* @return array<int,array{id:int,title:string,starts_at:string,at:string,cal:string,href:string}>
|
||||||
|
* `at` = 该触发提醒的时刻(starts_at - 提前分钟数)
|
||||||
|
*
|
||||||
|
* ⚠️ 为什么**只按 starts_at 查**、不做重复日程展开:
|
||||||
|
* 重复日程(RRULE)会在每周三都"提醒一次",但那需要把系列展开到具体某天,
|
||||||
|
* 而**同一次发生**在任何一次轮询里都必须算出**同一个提醒时刻**(否则会重复弹)。
|
||||||
|
* 窗口很短(前端每次只看"现在前后几分钟"),而"下次发生"恰好落在这几分钟里的
|
||||||
|
* 概率极低、影响也只是一个提醒来得晚一点。
|
||||||
|
* ⇒ 这一版覆盖"用户举的那个例子"(单次日程 10:00 提前 10 分钟 → 9:50 弹),
|
||||||
|
* 重复日程的提醒留待需要时再展开(不假装支持)。
|
||||||
|
*
|
||||||
|
* ⚠️ 多档提醒(`reminder_minutes` 是 `'5,15'`)会返回**多条**(每档一条):
|
||||||
|
* 用户既然设了两档,就该被提醒两次 —— 靠前端按 `id@at` 去重,不会重复弹同一条。
|
||||||
|
*/
|
||||||
|
public static function remindersBetween(int $familyId, int $personId,
|
||||||
|
string $from, string $to): array
|
||||||
|
{
|
||||||
|
// 我可见的日历本 = 我建的 + 共享给我的(与页面口径一致,别另发明一套)
|
||||||
|
$books = [];
|
||||||
|
foreach (self::mine($familyId, $personId) as $c) {
|
||||||
|
if (in_array((string)$c['kind'], ['movie', 'trip'], true)) {
|
||||||
|
continue; // 派生本没有 calendar_events 行,且它们本就没有提醒
|
||||||
|
}
|
||||||
|
$books[] = (int)$c['id'];
|
||||||
|
}
|
||||||
|
if (!$books) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$in = implode(',', array_fill(0, count($books), '?'));
|
||||||
|
// ⚠️ 范围要放宽 `最大档位` 分钟:提醒时刻 = 开始 - N 分钟,
|
||||||
|
// 所以"开始时间"可能在窗口**之后**(10:00 的事件,9:50 时窗口是过去)。
|
||||||
|
// 取 REMINDERS 里最大档位当余量,一次查全。
|
||||||
|
$maxLead = max(array_keys(self::REMINDERS));
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
"SELECT e.id, e.title, e.starts_at, e.reminder_minutes, e.all_day,
|
||||||
|
c.name AS calname
|
||||||
|
FROM calendar_events e
|
||||||
|
JOIN calendars c ON c.id = e.calendar_id
|
||||||
|
WHERE e.calendar_id IN ($in)
|
||||||
|
AND e.reminder_minutes IS NOT NULL AND e.reminder_minutes <> ''
|
||||||
|
AND e.status <> 'cancelled'
|
||||||
|
AND e.starts_at >= ? AND e.starts_at <= ?
|
||||||
|
ORDER BY e.starts_at"
|
||||||
|
);
|
||||||
|
$st->execute(array_merge($books,
|
||||||
|
[date('Y-m-d H:i:s', strtotime($from) - $maxLead * 60),
|
||||||
|
date('Y-m-d H:i:s', strtotime($to) + $maxLead * 60)]));
|
||||||
|
$out = [];
|
||||||
|
$fTs = strtotime($from);
|
||||||
|
$tTs = strtotime($to);
|
||||||
|
foreach ($st->fetchAll() as $e) {
|
||||||
|
if (!empty($e['all_day'])) {
|
||||||
|
continue; // 全天日程没有"开始时点",提前 N 分钟无从计算
|
||||||
|
}
|
||||||
|
$sTs = strtotime((string)$e['starts_at']);
|
||||||
|
if ($sTs === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (self::reminderList($e['reminder_minutes'] ?? null) as $lead) {
|
||||||
|
$atTs = $sTs - $lead * 60;
|
||||||
|
if ($atTs < $fTs || $atTs > $tTs) {
|
||||||
|
continue; // 这一档的提醒时刻不在本窗口
|
||||||
|
}
|
||||||
|
$out[] = [
|
||||||
|
'id' => (int)$e['id'],
|
||||||
|
'title' => (string)$e['title'],
|
||||||
|
'starts_at' => (string)$e['starts_at'],
|
||||||
|
'at' => date('Y-m-d H:i:s', $atTs),
|
||||||
|
'lead' => (int)$lead,
|
||||||
|
'cal' => (string)$e['calname'],
|
||||||
|
'href' => url('calendar/event', ['id' => (int)$e['id']]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($out, static fn(array $a, array $b): int => strcmp($a['at'], $b['at']));
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任意形态的提醒值 → **合法分钟数数组**(升序、去重)。
|
||||||
* ⚠️ 白名单:只认 `self::REMINDERS` 里那几个档位。手搓请求塞 999999
|
* ⚠️ 白名单:只认 `self::REMINDERS` 里那几个档位。手搓请求塞 999999
|
||||||
* 不能变成"提前 999999 分钟提醒"(那会导出一个客户端算不明白的 VALARM)。
|
* 不能变成"提前 999999 分钟提醒"(那会导出一个客户端算不明白的 VALARM)。
|
||||||
*
|
*
|
||||||
|
|||||||
+174
-1
@@ -38,10 +38,44 @@ namespace App;
|
|||||||
|
|
||||||
final class Notification
|
final class Notification
|
||||||
{
|
{
|
||||||
/** 宿主类型(与 attachments.target_type 同一套词,便于将来复用跳转逻辑) */
|
/**
|
||||||
|
* 宿主类型(与 attachments.target_type 同一套词,便于将来复用跳转逻辑)
|
||||||
|
*
|
||||||
|
* ⚠️ 这里的值有**两种用途**,别混:
|
||||||
|
* · 前两个(movie / trip)是 `syncForTarget()` 的"组"类型,
|
||||||
|
* 唯一键 (person_id, target_type, target_id) 靠它去重与撤销;
|
||||||
|
* · 后面几个是 v32 新增的**其它"别人给我"的场景**,各自对应一张共享表。
|
||||||
|
*/
|
||||||
public const MOVIE = 'movie';
|
public const MOVIE = 'movie';
|
||||||
public const TRIP = 'trip';
|
public const TRIP = 'trip';
|
||||||
|
|
||||||
|
/** v32:别人把东西**共享**给我(日历本 / 笔记 / 资源)—— 各自的宿主类型 */
|
||||||
|
public const CALENDAR = 'calendar';
|
||||||
|
public const NOTE = 'note';
|
||||||
|
public const RESOURCE = 'resource';
|
||||||
|
|
||||||
|
/** 这几类宿主"保存后同步通知"的注册表:类型 → 表名/列名/跳转/词 */
|
||||||
|
public const SHARE_KINDS = [
|
||||||
|
self::CALENDAR => [
|
||||||
|
'table' => 'calendar_shares', 'col' => 'calendar_id',
|
||||||
|
'sub' => 'calendars', 'sub_id' => 'id', 'sub_name' => 'name',
|
||||||
|
'href' => 'calendar/calendars', 'word' => '日历本',
|
||||||
|
'icon' => '📅',
|
||||||
|
],
|
||||||
|
self::NOTE => [
|
||||||
|
'table' => 'note_shares', 'col' => 'note_id',
|
||||||
|
'sub' => 'notes', 'sub_id' => 'id', 'sub_name' => 'title',
|
||||||
|
'href' => 'notes/form', 'word' => '笔记',
|
||||||
|
'icon' => '📝',
|
||||||
|
],
|
||||||
|
self::RESOURCE => [
|
||||||
|
'table' => 'resource_shares', 'col' => 'resource_id',
|
||||||
|
'sub' => 'resources', 'sub_id' => 'id', 'sub_name' => 'title',
|
||||||
|
'href' => 'resources', 'word' => '资源',
|
||||||
|
'icon' => '📦',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
/** 列表一次最多显示多少条(首页那块只取前几条,见 unreadCount / recent) */
|
/** 列表一次最多显示多少条(首页那块只取前几条,见 unreadCount / recent) */
|
||||||
public const PAGE_SIZE = 50;
|
public const PAGE_SIZE = 50;
|
||||||
|
|
||||||
@@ -111,6 +145,107 @@ final class Notification
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v32(用户 2026-10-08 第 2 条):「消息通知,在所有引用自己的地方,都通知,
|
||||||
|
* 包括别人分享了日历本给自己,分享了笔记给自己,分享了资源给自己等等。」
|
||||||
|
*
|
||||||
|
* 与 `syncForTarget()` 的分工:
|
||||||
|
* · syncForTarget:**参与人是"整组替换"**的场合(观影/旅程加人)——
|
||||||
|
* 每次保存都要"按差额撤销",所以要传全量 keepIds。
|
||||||
|
* · 本函数:共享表是**逐条增删**的(一次只动一个人),
|
||||||
|
* 所以不需要撤销逻辑 —— 谁多了就发一条,谁被移走就删他未读的那条。
|
||||||
|
*
|
||||||
|
* ⚠️ 直接**照共享表现状**重建通知,而不是"按调用方说的动作发":
|
||||||
|
* 共享的入口不止一个(列表页共享、详情页取消共享、看板/笔记页…),
|
||||||
|
* 每个入口都记得发通知是不可能的 —— 迟早漏一个(本批就是这么发现的:
|
||||||
|
* 观影/旅程有通知,日历本/笔记/资源没有)。
|
||||||
|
* ⇒ 在**共享表的每次写入之后**调一次本函数,它自己算差额。
|
||||||
|
*
|
||||||
|
* @param string $kind self::CALENDAR / self::NOTE / self::RESOURCE
|
||||||
|
* @param int $hostId 宿主 id
|
||||||
|
* @param int $actorId 谁共享的(**不给动作发出者自己发**,见头部第 ②条)
|
||||||
|
*/
|
||||||
|
public static function syncShares(int $familyId, string $kind, int $hostId, int $actorId): void
|
||||||
|
{
|
||||||
|
$cfg = self::SHARE_KINDS[$kind] ?? null;
|
||||||
|
if ($cfg === null || $hostId <= 0 || $familyId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$db = Database::get();
|
||||||
|
|
||||||
|
// ---- 现在共享给了谁(共享表为准)----
|
||||||
|
$st = $db->prepare(
|
||||||
|
'SELECT ' . $cfg['col'] . ' AS hid, person_id, perm FROM ' . $cfg['table']
|
||||||
|
. ' WHERE ' . $cfg['col'] . ' = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$hostId]);
|
||||||
|
$now = [];
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
$pid = (int)$r['person_id'];
|
||||||
|
if ($pid > 0 && $pid !== $actorId) {
|
||||||
|
$now[$pid] = (string)$r['perm'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 撤销:不在共享表里、且**未读**的,删掉(已读的是历史,见头部第 ③条)----
|
||||||
|
$st = $db->prepare(
|
||||||
|
'SELECT id, person_id FROM notifications
|
||||||
|
WHERE target_type = ? AND target_id = ? AND is_read = 0'
|
||||||
|
);
|
||||||
|
$st->execute([$kind, $hostId]);
|
||||||
|
$del = $db->prepare('DELETE FROM notifications WHERE id = ?');
|
||||||
|
foreach ($st->fetchAll() as $row) {
|
||||||
|
if (!isset($now[(int)$row['person_id']])) {
|
||||||
|
$del->execute([(int)$row['id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$now) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 宿主的名字(通知里要显示"「xxx」")----
|
||||||
|
$st = $db->prepare(
|
||||||
|
'SELECT ' . $cfg['sub_name'] . ' AS nm FROM ' . $cfg['sub']
|
||||||
|
. ' WHERE ' . $cfg['sub_id'] . ' = ? LIMIT 1'
|
||||||
|
);
|
||||||
|
$st->execute([$hostId]);
|
||||||
|
$hostName = (string)($st->fetchColumn() ?: '');
|
||||||
|
|
||||||
|
// ---- 新增(INSERT IGNORE 靠唯一键去重:反复保存只留一条,见头部第 ④条)----
|
||||||
|
// ⚠️ 正文要带**权限**:用户看到"只读 / 可读写"才知道自己能做什么。
|
||||||
|
$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())'
|
||||||
|
);
|
||||||
|
foreach ($now as $pid => $perm) {
|
||||||
|
$permWord = $perm === 'write' ? '可读写' : '只读';
|
||||||
|
$body = '把' . $cfg['word'] . '「' . self::clip($hostName, 60) . '」共享给你('
|
||||||
|
. $permWord . ')';
|
||||||
|
$ins->execute([
|
||||||
|
$familyId, (int)$pid, $actorId, $kind, $body,
|
||||||
|
$cfg['href'],
|
||||||
|
$kind === self::NOTE ? json_encode(['id' => $hostId], JSON_UNESCAPED_UNICODE) : null,
|
||||||
|
$kind, $hostId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// ⚠️ 权限是"可变"的:用户把只读改成可读写时,上面那条 INSERT IGNORE
|
||||||
|
// 不会更新已有的正文 ⇒ 通知里还写着"只读"(与实际不符)。
|
||||||
|
// ⇒ 未读的那条**顺手把正文刷新一遍**(已读的不动:那是历史)。
|
||||||
|
$upd = $db->prepare(
|
||||||
|
'UPDATE notifications SET title = ? WHERE target_type = ? AND target_id = ?
|
||||||
|
AND person_id = ? AND is_read = 0'
|
||||||
|
);
|
||||||
|
foreach ($now as $pid => $perm) {
|
||||||
|
$permWord = $perm === 'write' ? '可读写' : '只读';
|
||||||
|
$upd->execute([
|
||||||
|
'把' . $cfg['word'] . '「' . self::clip($hostName, 60) . '」共享给你(' . $permWord . ')',
|
||||||
|
$kind, $hostId, (int)$pid,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 宿主被删除时,把它的通知一并清掉。
|
* 宿主被删除时,把它的通知一并清掉。
|
||||||
* ⚠️ 不做外键(target_type 指向多张表,外键表达不了)→ 必须手动清,
|
* ⚠️ 不做外键(target_type 指向多张表,外键表达不了)→ 必须手动清,
|
||||||
@@ -204,6 +339,44 @@ final class Notification
|
|||||||
return url((string)$n['href'], $params);
|
return url((string)$n['href'], $params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知的图标(按 kind)。
|
||||||
|
*
|
||||||
|
* ⚠️ 抽成一处:通知页与首页那块**都**要画图标,两处各写一套 emoji 分支
|
||||||
|
* 迟早漂移(本项目的"设置下拉"就吃过这个亏)。模板里只调这个函数。
|
||||||
|
*/
|
||||||
|
public static function icon(string $kind): string
|
||||||
|
{
|
||||||
|
$map = [
|
||||||
|
self::MOVIE => '🎬',
|
||||||
|
self::TRIP => '🧭',
|
||||||
|
self::CALENDAR => '📅',
|
||||||
|
self::NOTE => '📝',
|
||||||
|
self::RESOURCE => '📦',
|
||||||
|
];
|
||||||
|
if (isset($map[$kind])) {
|
||||||
|
return $map[$kind];
|
||||||
|
}
|
||||||
|
// 旅程的宿主类型是 'trip_node' 之类(前缀匹配)
|
||||||
|
if (strpos($kind, 'trip') === 0) {
|
||||||
|
return '🧭';
|
||||||
|
}
|
||||||
|
return '🔔';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知里的动作词("有人**把你加进了**观影「x」" vs "有人**把笔记共享给你**")。
|
||||||
|
*
|
||||||
|
* ⚠️ 正文是**存库**的(`title`),这里只负责"把开头的『有人』换成实际名字"
|
||||||
|
* 这件事在模板里怎么做 —— 见 `bodyWithoutActor()`。
|
||||||
|
*/
|
||||||
|
public static function bodyWithoutActor(string $title): string
|
||||||
|
{
|
||||||
|
// 正文都是「有人…」开头(见 syncForTarget / syncShares 的拼法);
|
||||||
|
// 模板会把 `<b>他的名字</b>` 放在前面,所以这里把开头的"有人"去掉。
|
||||||
|
return (string)preg_replace('/^有人/u', '', $title);
|
||||||
|
}
|
||||||
|
|
||||||
/** 文案截断(多字节安全) */
|
/** 文案截断(多字节安全) */
|
||||||
private static function clip(string $s, int $max): string
|
private static function clip(string $s, int $max): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* 浏览器通知(v32 第 3 条)。
|
||||||
|
*
|
||||||
|
* 用户原话:「当有消息通知时,还有日历中日程到期提醒时,比如一个日程,设置了10点开始,
|
||||||
|
* 提前十分钟提醒,则需要在 9:50 时浏览器中发提醒通知。」
|
||||||
|
*
|
||||||
|
* 做两件事:
|
||||||
|
* ① 定期问服务端 `?r=notifications/poll`(**到点与否由 PHP 算**,见 index.php 那段);
|
||||||
|
* ② 把该响的用 Notification API 弹出来(站内消息 + 日程提醒)。
|
||||||
|
*
|
||||||
|
* ────────────────────────────────────────────────────────────────────
|
||||||
|
* 五条设计判断(每条都对应一个"不这么做就会出的问题"):
|
||||||
|
*
|
||||||
|
* ① **权限只在用户"动手"时要,绝不在页面加载时自动弹**。
|
||||||
|
* 浏览器对"未经交互就 requestPermission"的做法会直接**静默拒绝**
|
||||||
|
* (Chrome 的 abusiveness 规则),而且会把本域名拉进"用户不喜欢"的名单。
|
||||||
|
* ⇒ 页头放一个小铃铛按钮(`.fb-notify-enable`),用户点了才要权限。
|
||||||
|
* 用户没开权限时轮询**照常跑**(站内未读数还得更新),只是不弹系统通知。
|
||||||
|
*
|
||||||
|
* ② **同一个提醒只弹一次**。轮询窗口故意留了"过去 2 分钟"(见后端注释:
|
||||||
|
* 防标签页节流导致整条错过),代价是同一档提醒可能连续两轮都落进窗口。
|
||||||
|
* ⇒ 用 `已弹过的 key 集合`(`事件id@提醒时刻`)去重,且集合只在**内存**里 ——
|
||||||
|
* 刷新页面会重弹一次"刚刚那条",这反而是对的(用户刷新说明他还没看到)。
|
||||||
|
*
|
||||||
|
* ③ **点通知要能回到那个东西**。`new Notification(...).onclick` 里
|
||||||
|
* `window.focus()` + `location.href = 链接`。
|
||||||
|
* ⚠️ 用 `tag` 而不是 `new Notification` 的自定义字段:部分浏览器只认 tag,
|
||||||
|
* 不认自定义属性(用 tag 还能让同一件事的通知**合并**而不是堆一列)。
|
||||||
|
*
|
||||||
|
* ④ **后台标签页也要能弹**。`setInterval` 在后台标签页会被浏览器节流到
|
||||||
|
* 1~2 分钟一次(甚至更慢)。所以**轮询间隔取 60 秒**(与节流后的粒度相当),
|
||||||
|
* 并把窗口开到"过去 2 分钟"把这层抖动吸收掉 —— 而不是去和浏览器对抗。
|
||||||
|
*
|
||||||
|
* ⑤ **不用 Service Worker / 推送服务**。那要 HTTPS + 服务端推送通道,
|
||||||
|
* 而用户要的是"页面开着时能提醒"。页面关掉还提醒属于 Web Push 的范畴,
|
||||||
|
* 是另一件事(也超出"站内"的边界,与 Notification 头部第 ⑤条同一取舍)。
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var API = (window.FB_NOTIFY && window.FB_NOTIFY.poll) || '';
|
||||||
|
if (!API) { return; }
|
||||||
|
|
||||||
|
var POLL_MS = 60000; // 见头部第 ④条
|
||||||
|
var seen = {}; // 已弹过的提醒 key(见头部第 ②条)
|
||||||
|
var lastUnread = null; // 上一轮的未读数:只在**变多**时提醒,避免反复骚扰
|
||||||
|
var timer = null;
|
||||||
|
|
||||||
|
function supported() {
|
||||||
|
return (typeof window.Notification === 'function');
|
||||||
|
}
|
||||||
|
|
||||||
|
function enabled() {
|
||||||
|
return supported() && window.Notification.permission === 'granted';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 弹一条系统通知;点了就回到对应的页面 */
|
||||||
|
function fire(title, body, href, tag, icon) {
|
||||||
|
try {
|
||||||
|
var n = new window.Notification(title, {
|
||||||
|
body: body || '',
|
||||||
|
tag: tag || '', // 同 tag 合并(见头部第 ③条)
|
||||||
|
icon: icon || ''
|
||||||
|
});
|
||||||
|
n.onclick = function () {
|
||||||
|
try { window.focus(); } catch (e) { /* 有些浏览器不允许抢焦点 */ }
|
||||||
|
if (href) { window.location.href = href; }
|
||||||
|
try { n.close(); } catch (e) { /* 已关闭 */ }
|
||||||
|
};
|
||||||
|
// 8 秒后自动收起:提醒条一直在会挡住下一个
|
||||||
|
setTimeout(function () { try { n.close(); } catch (e) { /* 已关 */ } }, 8000);
|
||||||
|
} catch (e) {
|
||||||
|
// 某些浏览器在"页面不可见"时抛(旧实现)—— 忽略,别让它影响轮询
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function poll() {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('GET', API, true);
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
|
xhr.onload = function () {
|
||||||
|
if (xhr.status !== 200) { return; }
|
||||||
|
var d;
|
||||||
|
try { d = JSON.parse(xhr.responseText); } catch (e) { return; }
|
||||||
|
handle(d);
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(d) {
|
||||||
|
if (!d) { return; }
|
||||||
|
|
||||||
|
// ---- 站内消息:只在**未读数变多**时弹一次(否则每轮都弹同样的几条)----
|
||||||
|
var unread = parseInt(d.unread || 0, 10);
|
||||||
|
if (enabled() && unread > 0 && lastUnread !== null && unread > lastUnread) {
|
||||||
|
var first = (d.items && d.items[0]) || null;
|
||||||
|
fire('家书 · 新消息',
|
||||||
|
first ? (first.actor + first.body) : ('你有 ' + unread + ' 条新消息'),
|
||||||
|
(window.FB_NOTIFY && window.FB_NOTIFY.list) || '',
|
||||||
|
'fb-notify', (window.FB_NOTIFY && window.FB_NOTIFY.icon) || '');
|
||||||
|
}
|
||||||
|
lastUnread = unread;
|
||||||
|
|
||||||
|
// ---- 日程提醒:按 `id@at` 去重(见头部第 ②条)----
|
||||||
|
var rs = d.reminders || [];
|
||||||
|
for (var i = 0; i < rs.length; i++) {
|
||||||
|
var r = rs[i];
|
||||||
|
var key = r.id + '@' + r.at + '@' + (r.lead || '');
|
||||||
|
if (seen[key]) { continue; }
|
||||||
|
seen[key] = true;
|
||||||
|
if (!enabled()) { continue; }
|
||||||
|
var t = String(r.starts_at || '').substr(11, 5);
|
||||||
|
fire('日程提醒:' + (r.title || '(无标题)'),
|
||||||
|
(r.lead ? ('还有 ' + r.lead + ' 分钟开始') : '马上开始')
|
||||||
|
+ (t ? ('(' + t + ')') : '') + (r.cal ? (' · ' + r.cal) : ''),
|
||||||
|
r.href || '', 'fb-remind-' + r.id + '-' + (r.lead || ''), '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 页头那个小铃铛:点一下要权限(见头部第 ①条) */
|
||||||
|
function bindButton() {
|
||||||
|
var btn = document.querySelector('.fb-notify-enable');
|
||||||
|
if (!btn) { return; }
|
||||||
|
var paint = function () {
|
||||||
|
var on = enabled();
|
||||||
|
btn.hidden = on || !supported();
|
||||||
|
btn.title = '开启浏览器提醒';
|
||||||
|
btn.setAttribute('aria-label', '开启浏览器提醒');
|
||||||
|
};
|
||||||
|
btn.hidden = enabled() || !supported();
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (!supported()) { return; }
|
||||||
|
window.Notification.requestPermission().then(function () {
|
||||||
|
paint();
|
||||||
|
if (enabled()) {
|
||||||
|
fire('家书 · 提醒已开启', '有新的消息或日程提醒时,我会在这里通知你。', '', 'fb-on', '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
paint();
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
bindButton();
|
||||||
|
poll(); // 先拉一次(页面刚打开时可能有未读)
|
||||||
|
timer = setInterval(poll, POLL_MS);
|
||||||
|
// 页面重新可见时立刻补一次:标签页在后台被节流,回来时可能已经错过窗口
|
||||||
|
document.addEventListener('visibilitychange', function () {
|
||||||
|
if (!document.hidden) { poll(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', start);
|
||||||
|
} else {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 供探针/调试:手动触发一次
|
||||||
|
window.fbNotifyPoll = poll;
|
||||||
|
})();
|
||||||
@@ -1419,6 +1419,53 @@ switch ($r) {
|
|||||||
redirect('notifications');
|
redirect('notifications');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v32 第 3 条:**浏览器通知**的轮询口(JSON)。
|
||||||
|
*
|
||||||
|
* 用户原话:「当有消息通知时,还有日历中日程到期提醒时…则需要在 9:50 时
|
||||||
|
* 浏览器中发提醒通知。」
|
||||||
|
*
|
||||||
|
* 前端每 60 秒拉一次,本口回两样东西:
|
||||||
|
* · `unread` —— 未读站内消息(前端弹"你有 N 条新消息",**不进通知页就不标已读**)
|
||||||
|
* · `reminders` —— 落在 [now-2min, now+30s] 窗口内的日程提醒
|
||||||
|
*
|
||||||
|
* ⚠️ 为什么"到点"要在**服务端**算,而不是前端拿 starts_at 自己减:
|
||||||
|
* 时区、`reminder_minutes` 是逗号串(多档)、重复日程…这些判断都在
|
||||||
|
* PHP 侧已经有一套(reminderList / 可见性),前端再写一遍必然漂移。
|
||||||
|
* 前端只负责"把服务端说该响的响出来"。
|
||||||
|
*
|
||||||
|
* ⚠️ 窗口要**留过去的一点**(-2 分钟):浏览器标签页休眠/被节流时轮询会迟到,
|
||||||
|
* 只取"未来 30 秒"会整整错过一条(用户会看到"明明设了提醒却没弹")。
|
||||||
|
* ⚠️ 但不能留太长:会把"我刚打开页面"时早就过去的提醒一次性全弹出来。
|
||||||
|
*
|
||||||
|
* ⚠️ **不标已读**:浏览器的提示条不是"打开消息页",不该把消息标成已读。
|
||||||
|
* 标已读只在用户真的进 `?r=notifications` 时发生。
|
||||||
|
*/
|
||||||
|
case 'notifications/poll':
|
||||||
|
Auth::requireLogin();
|
||||||
|
$me = (int)Auth::personId();
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
$now = time();
|
||||||
|
echo json_encode([
|
||||||
|
'unread' => Notification::unreadCount($me),
|
||||||
|
'items' => array_map(static function (array $n): array {
|
||||||
|
return [
|
||||||
|
'id' => (int)$n['id'],
|
||||||
|
'actor' => (string)($n['actor_name'] ?? ''),
|
||||||
|
'body' => Notification::bodyWithoutActor((string)$n['title']),
|
||||||
|
'icon' => Notification::icon((string)($n['kind'] ?? '')),
|
||||||
|
'href' => Notification::href($n),
|
||||||
|
];
|
||||||
|
}, Notification::mine($me, 5, true)),
|
||||||
|
'reminders' => Calendar::remindersBetween(
|
||||||
|
$familyId, $me,
|
||||||
|
date('Y-m-d H:i:s', $now - 120),
|
||||||
|
date('Y-m-d H:i:s', $now + 30)
|
||||||
|
),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
|
||||||
// ==================== 我自己的资料 / 个人设置 ====================
|
// ==================== 我自己的资料 / 个人设置 ====================
|
||||||
// 顶栏头像点开 →「个人设置」。访客也能改自己的资料 ——
|
// 顶栏头像点开 →「个人设置」。访客也能改自己的资料 ——
|
||||||
// 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。
|
// 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。
|
||||||
@@ -4460,6 +4507,11 @@ $want = array_keys($want);
|
|||||||
redirect('calendar/calendars');
|
redirect('calendar/calendars');
|
||||||
}
|
}
|
||||||
Calendar::setShare($id, $target, (string)($_POST['perm'] ?? 'read'));
|
Calendar::setShare($id, $target, (string)($_POST['perm'] ?? 'read'));
|
||||||
|
// v32 第 2 条:别人把日历本共享给我 → 给我发一条通知
|
||||||
|
// ⚠️ 放在**共享表写入之后**、按表现状重建(用户会反复改权限),
|
||||||
|
// 不要在这里手写"给谁发"——见 Notification::syncShares 的说明。
|
||||||
|
Notification::syncShares((int)Auth::familyId(), Notification::CALENDAR,
|
||||||
|
$id, $me);
|
||||||
flash('ok', '已共享。');
|
flash('ok', '已共享。');
|
||||||
redirect('calendar/calendars');
|
redirect('calendar/calendars');
|
||||||
break;
|
break;
|
||||||
@@ -4475,6 +4527,10 @@ $want = array_keys($want);
|
|||||||
redirect('calendar/calendars');
|
redirect('calendar/calendars');
|
||||||
}
|
}
|
||||||
Calendar::dropShare($id, (int)($_POST['person_id'] ?? 0));
|
Calendar::dropShare($id, (int)($_POST['person_id'] ?? 0));
|
||||||
|
// v32 第 2 条:被移出共享 → 把他**未读**的那条通知一并撤销
|
||||||
|
//(已读的保留:那是他看过的历史,删了像"消息会自己消失")
|
||||||
|
Notification::syncShares((int)Auth::familyId(), Notification::CALENDAR,
|
||||||
|
$id, $me);
|
||||||
flash('ok', '已取消共享。');
|
flash('ok', '已取消共享。');
|
||||||
redirect('calendar/calendars');
|
redirect('calendar/calendars');
|
||||||
break;
|
break;
|
||||||
@@ -5119,6 +5175,9 @@ $want = array_keys($want);
|
|||||||
$target = (int)($_POST['person_id'] ?? 0);
|
$target = (int)($_POST['person_id'] ?? 0);
|
||||||
if ($target > 0 && isset(Person::map($familyId)[$target])) {
|
if ($target > 0 && isset(Person::map($familyId)[$target])) {
|
||||||
Resource::setShare($id, (int)$r0['owner_id'], $target, (string)($_POST['perm'] ?? 'read'));
|
Resource::setShare($id, (int)$r0['owner_id'], $target, (string)($_POST['perm'] ?? 'read'));
|
||||||
|
// v32 第 1+2 条:资源共享(顺带修掉"弹框里点共享没生效"之后,
|
||||||
|
// 这里才真正会被走到)→ 按共享表现状给被共享的人发通知
|
||||||
|
Notification::syncShares($familyId, Notification::RESOURCE, $id, $me);
|
||||||
flash('ok', '已共享。');
|
flash('ok', '已共享。');
|
||||||
}
|
}
|
||||||
redirect('resources');
|
redirect('resources');
|
||||||
@@ -5133,6 +5192,8 @@ $want = array_keys($want);
|
|||||||
$r0 = Resource::find($familyId, $id, $me);
|
$r0 = Resource::find($familyId, $id, $me);
|
||||||
if ($r0 && Resource::canWrite($r0, $me)) {
|
if ($r0 && Resource::canWrite($r0, $me)) {
|
||||||
Resource::dropShare($id, (int)($_POST['person_id'] ?? 0));
|
Resource::dropShare($id, (int)($_POST['person_id'] ?? 0));
|
||||||
|
// v32 第 2 条:撤销他未读的那条共享通知(已读的保留)
|
||||||
|
Notification::syncShares($familyId, Notification::RESOURCE, $id, $me);
|
||||||
flash('ok', '已取消共享。');
|
flash('ok', '已取消共享。');
|
||||||
}
|
}
|
||||||
redirect('resources');
|
redirect('resources');
|
||||||
@@ -6671,6 +6732,8 @@ $want = array_keys($want);
|
|||||||
redirect('notes/form', ['id' => $id]);
|
redirect('notes/form', ['id' => $id]);
|
||||||
}
|
}
|
||||||
Note::setShare($id, $me, $pid, (string)($_POST['perm'] ?? 'read'));
|
Note::setShare($id, $me, $pid, (string)($_POST['perm'] ?? 'read'));
|
||||||
|
// v32 第 2 条:别人把笔记共享给我 → 发一条通知(按共享表现状重建)
|
||||||
|
Notification::syncShares($familyId, Notification::NOTE, $id, $me);
|
||||||
flash('ok', '共享已更新。');
|
flash('ok', '共享已更新。');
|
||||||
redirect('notes/form', ['id' => $id]);
|
redirect('notes/form', ['id' => $id]);
|
||||||
break;
|
break;
|
||||||
@@ -6688,6 +6751,8 @@ $want = array_keys($want);
|
|||||||
redirect('notes');
|
redirect('notes');
|
||||||
}
|
}
|
||||||
Note::dropShare($id, $pid);
|
Note::dropShare($id, $pid);
|
||||||
|
// v32 第 2 条:撤销他未读的那条共享通知(已读的保留)
|
||||||
|
Notification::syncShares($familyId, Notification::NOTE, $id, $me);
|
||||||
flash('ok', '已取消共享。');
|
flash('ok', '已取消共享。');
|
||||||
redirect('notes/form', ['id' => $id]);
|
redirect('notes/form', ['id' => $id]);
|
||||||
break;
|
break;
|
||||||
|
|||||||
+21
-13
@@ -369,17 +369,24 @@ unset($evRef);
|
|||||||
<?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); ?>
|
||||||
<?php
|
<?php
|
||||||
/* 🔴 v30 第 3 条:「首页,近期任务,任务的名称和前面的点,都应该和主题颜色
|
/* 🔴 v32 第 4 条(用户 2026-10-08):「首页,近期任务,任务的文字和前面的圆点,
|
||||||
一样,都保持一致。」
|
颜色并没有跟**看板颜色**一致,这个需要修正。」
|
||||||
用户说的"主题颜色"= **任务模块的主题色**(个人设置里可换的那个)。
|
|
||||||
⚠️ 原来圆点用的是 `var(--brand)` —— 那是**当前页面**(首页)的主题色,
|
⚠️ v30 那轮我把它改成了 `--tk-c`(= **任务模块**的主题色),
|
||||||
不是任务模块的;两者在用户改过模块配色时会不一样
|
当时理解成"用户说的主题色"。**理解错了** —— 用户要的是
|
||||||
(首页 = 砖红,任务可能是别的色),正是用户看到的不一致。
|
「这条任务属于哪个**看板**」的颜色(任务模块自己早就支持
|
||||||
⇒ 两条都用同一个 `--tk-c`(= Module::color('tasks')),
|
"给看板选颜色",用户原话在第 26 批:「任务在设置看板的时候,
|
||||||
与右上角「全部任务」按钮的 `--hd-c` 同源,全块一个色。
|
可以选择颜色,这样在首页列表中显示的时候,就可以更加直观的
|
||||||
⚠️ 逾期仍用红色:那是状态语义(要显眼),不能被主题色盖掉;
|
看到他属于哪个看板。」)。
|
||||||
但改名后的那个点也统一走 `--tk-c`(不逾期时)。 */
|
⇒ 用 `Task::boardColorById(家庭, 看板id)`:
|
||||||
$tkColor = \App\Module::color('tasks', (int)\App\Auth::familyId());
|
看板没设色时它按**看板名派生**一个稳定色(同名永远同色),
|
||||||
|
所以这里不需要任何回退分支。
|
||||||
|
⚠️ 逾期仍用红色(状态语义,不能被看板色盖掉)——
|
||||||
|
但那是"整行变红",与"没逾期时跟看板一致"并不冲突。
|
||||||
|
⚠️ 没归看板的(board_id 为空)走"默认看板"的派生色,
|
||||||
|
与任务模块列表页看到的完全一致(同一函数)。 */
|
||||||
|
$tkColor = \App\Task::boardColorById((int)\App\Auth::familyId(),
|
||||||
|
(int)($t['board_id'] ?? 0) ?: null);
|
||||||
?>
|
?>
|
||||||
<li class="dash-row tk-row" data-task-row="<?= (int)$t['id'] ?>"
|
<li class="dash-row tk-row" data-task-row="<?= (int)$t['id'] ?>"
|
||||||
style="--tk-c: <?= e($tkColor) ?>;">
|
style="--tk-c: <?= e($tkColor) ?>;">
|
||||||
@@ -453,11 +460,12 @@ unset($evRef);
|
|||||||
);
|
);
|
||||||
if (trim((string)$actorName) === '') { $actorName = '有人'; }
|
if (trim((string)$actorName) === '') { $actorName = '有人'; }
|
||||||
?>
|
?>
|
||||||
<li class="ntf-row<?= empty($n['is_read']) ? ' is-unread' : '' ?>">
|
<li class="ntf-row ntf-row-<?= e((string)($n['kind'] ?? '')) ?><?= empty($n['is_read']) ? ' is-unread' : '' ?>">
|
||||||
|
<span class="ntf-kind" aria-hidden="true"><?= \App\Notification::icon((string)($n['kind'] ?? '')) ?></span>
|
||||||
<img class="ntf-av" src="<?= e(avatar_url($n['actor_avatar'] ?? null)) ?>" alt="">
|
<img class="ntf-av" src="<?= e(avatar_url($n['actor_avatar'] ?? null)) ?>" alt="">
|
||||||
<a class="ntf-main" href="<?= e(\App\Notification::href($n)) ?>">
|
<a class="ntf-main" href="<?= e(\App\Notification::href($n)) ?>">
|
||||||
<span class="ntf-title">
|
<span class="ntf-title">
|
||||||
<b><?= e((string)$actorName) ?></b><?= e(preg_replace('/^有人/', '', (string)$n['title'])) ?>
|
<b><?= e((string)$actorName) ?></b><?= e(\App\Notification::bodyWithoutActor((string)$n['title'])) ?>
|
||||||
</span>
|
</span>
|
||||||
<span class="ntf-time"><?= e(fb_time_ago((string)$n['created_at'])) ?></span>
|
<span class="ntf-time"><?= e(fb_time_ago((string)$n['created_at'])) ?></span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -90,6 +90,21 @@ $isLoggedIn = \App\Auth::check();
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
|
<?php /* 🔴 v32 第 3 条:浏览器提醒的**开关**(小铃铛)。
|
||||||
|
用户原话:「…则需要在 9:50 时浏览器中发提醒通知。」
|
||||||
|
⚠️ 浏览器**不会**允许"页面加载时自动申请通知权限"(Chrome 会直接静默拒绝,
|
||||||
|
并把域名记进"滥用"名单),所以权限必须由用户**点一下**才要。
|
||||||
|
⇒ 这里放一个按钮,由 assets/js/notify.js 绑定。
|
||||||
|
⚠️ 已经授权时(或浏览器不支持 Notification 时)按钮自动隐藏 ——
|
||||||
|
见 notify.js 的 paint()。所以未授权的人才会看到它。 */ ?>
|
||||||
|
<button type="button" class="btn btn-ghost btn-icon fb-notify-enable" hidden
|
||||||
|
title="开启浏览器提醒" aria-label="开启浏览器提醒">
|
||||||
|
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 7a4 4 0 0 1 8 0v3l1 2H3l1-2z"/>
|
||||||
|
<path d="M6.5 12.5a1.6 1.6 0 0 0 3 0"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<?php /* 姓名 + 头像 = 一个下拉菜单,而不是「点名字改资料」+旁边两个裸链接。
|
<?php /* 姓名 + 头像 = 一个下拉菜单,而不是「点名字改资料」+旁边两个裸链接。
|
||||||
裸链接在窄屏只能靠 display:none 藏起来(一藏入口就没了),
|
裸链接在窄屏只能靠 display:none 藏起来(一藏入口就没了),
|
||||||
收进菜单之后同一个入口在手机上也点得到。
|
收进菜单之后同一个入口在手机上也点得到。
|
||||||
@@ -161,6 +176,21 @@ $isLoggedIn = \App\Auth::check();
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="<?= e(asset('/assets/js/app.js')) ?>"></script>
|
<script src="<?= e(asset('/assets/js/app.js')) ?>"></script>
|
||||||
|
<?php /* 🔴 v32 第 3 条:浏览器通知(站内消息 + 日程到期提醒)。
|
||||||
|
⚠️ **只在已登录时加载** —— 登录页轮询会一直 302 去登录页,白跑。
|
||||||
|
同理也不在"免登录的导航展示页"上跑(那页没有会话)。
|
||||||
|
⚠️ 里面**不含**任何权限申请:权限必须由用户点小铃铛才要(见 notify.js 头部第 ①条)。 */ ?>
|
||||||
|
<?php if (\App\Auth::check()): ?>
|
||||||
|
<script>
|
||||||
|
window.FB_NOTIFY = {
|
||||||
|
poll: <?= json_encode(url('notifications/poll')) ?>,
|
||||||
|
list: <?= json_encode(url('notifications')) ?>,
|
||||||
|
icon: <?= json_encode(asset('/assets/icons/icon-96.png')) ?>,
|
||||||
|
canPush: <?= json_encode(\App\Auth::canWrite()) ?>
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="<?= e(asset('/assets/js/notify.js')) ?>" defer></script>
|
||||||
|
<?php endif; ?>
|
||||||
<?php /* v24 第 20 批:选人组件脚本改成**按需加载**(页面上真有 `.pp` 才拉)。
|
<?php /* v24 第 20 批:选人组件脚本改成**按需加载**(页面上真有 `.pp` 才拉)。
|
||||||
⚠️ 理由与 Cropper.js 同一套:`person_picker.js` 只在放选人组件的页面才有用,
|
⚠️ 理由与 Cropper.js 同一套:`person_picker.js` 只在放选人组件的页面才有用,
|
||||||
而它并不小;每页都无条件带一份,等于让"没有选人组件"的页面白付流量。
|
而它并不小;每页都无条件带一份,等于让"没有选人组件"的页面白付流量。
|
||||||
|
|||||||
@@ -50,17 +50,17 @@ $items = $items ?? [];
|
|||||||
// 图标:按 kind 分(观影 = 胶片,旅程 = 脚印,其余 = 铃铛)
|
// 图标:按 kind 分(观影 = 胶片,旅程 = 脚印,其余 = 铃铛)
|
||||||
$kind = (string)($n['kind'] ?? '');
|
$kind = (string)($n['kind'] ?? '');
|
||||||
?>
|
?>
|
||||||
<li class="ntf-row<?= $isUnread ? ' is-unread' : '' ?>">
|
<li class="ntf-row ntf-row-<?= e($kind) ?><?= $isUnread ? ' is-unread' : '' ?>">
|
||||||
<img class="ntf-av" src="<?= e(avatar_url($n['actor_avatar'] ?? null)) ?>" alt="">
|
<img class="ntf-av" src="<?= e(avatar_url($n['actor_avatar'] ?? null)) ?>" alt="">
|
||||||
<a class="ntf-main" href="<?= e(\App\Notification::href($n)) ?>">
|
<a class="ntf-main" href="<?= e(\App\Notification::href($n)) ?>">
|
||||||
<span class="ntf-title">
|
<span class="ntf-title">
|
||||||
<b><?= e((string)$actorName) ?></b>
|
<b><?= e((string)$actorName) ?></b>
|
||||||
<?= e(preg_replace('/^有人/', '', (string)$n['title'])) ?>
|
<?= e(\App\Notification::bodyWithoutActor((string)$n['title'])) ?>
|
||||||
</span>
|
</span>
|
||||||
<span class="ntf-time"><?= e(fb_time_ago((string)$n['created_at'])) ?></span>
|
<span class="ntf-time"><?= e(fb_time_ago((string)$n['created_at'])) ?></span>
|
||||||
</a>
|
</a>
|
||||||
<span class="ntf-kind ntf-kind-<?= e($kind) ?>" aria-hidden="true">
|
<span class="ntf-kind ntf-kind-<?= e($kind) ?>" aria-hidden="true">
|
||||||
<?= $kind === 'movie' ? '🎬' : (strpos($kind, 'trip') === 0 ? '🧭' : '🔔') ?>
|
<?= \App\Notification::icon($kind) ?>
|
||||||
</span>
|
</span>
|
||||||
<form method="post" action="<?= e(url('notifications/del')) ?>" class="ntf-del">
|
<form method="post" action="<?= e(url('notifications/del')) ?>" class="ntf-del">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ $myPerson = (int)\App\Auth::personId();
|
|||||||
|
|
||||||
<?php /* ---- 页签 ① 信息 ---- */ ?>
|
<?php /* ---- 页签 ① 信息 ---- */ ?>
|
||||||
<div class="res-tabpage" data-res-page="info">
|
<div class="res-tabpage" data-res-page="info">
|
||||||
<form method="post" action="<?= e(url('resources/update')) ?>">
|
<form method="post" data-res-idform action="<?= e(url('resources/update')) ?>">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="">
|
<input type="hidden" name="id" value="">
|
||||||
<div class="res-pb">
|
<div class="res-pb">
|
||||||
@@ -367,7 +367,7 @@ $myPerson = (int)\App\Auth::personId();
|
|||||||
<div class="rd-att-list" id="resAttNow"></div>
|
<div class="rd-att-list" id="resAttNow"></div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" enctype="multipart/form-data"
|
<form method="post" enctype="multipart/form-data"
|
||||||
action="<?= e(url('resources/attach_add')) ?>" id="resAttForm">
|
action="<?= e(url('resources/attach_add')) ?>" id="resAttForm" data-res-idform>
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="">
|
<input type="hidden" name="id" value="">
|
||||||
<h3 class="res-sub">添加附件</h3>
|
<h3 class="res-sub">添加附件</h3>
|
||||||
@@ -387,7 +387,7 @@ $myPerson = (int)\App\Auth::personId();
|
|||||||
<?php /* ---- 页签 ③ 共享(原「共享」按钮那整套搬进来)---- */ ?>
|
<?php /* ---- 页签 ③ 共享(原「共享」按钮那整套搬进来)---- */ ?>
|
||||||
<div class="res-tabpage" data-res-page="share" hidden>
|
<div class="res-tabpage" data-res-page="share" hidden>
|
||||||
<div class="res-pb">
|
<div class="res-pb">
|
||||||
<form method="post" action="<?= e(url('resources/share')) ?>">
|
<form method="post" action="<?= e(url('resources/share')) ?>" data-res-idform>
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="">
|
<input type="hidden" name="id" value="">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -422,7 +422,7 @@ $myPerson = (int)\App\Auth::personId();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php /* 取消共享:唯一的一份表单,脚本填两个隐藏字段后提交。 */ ?>
|
<?php /* 取消共享:唯一的一份表单,脚本填两个隐藏字段后提交。 */ ?>
|
||||||
<form method="post" action="<?= e(url('resources/unshare')) ?>" id="resUnshareForm" hidden>
|
<form method="post" action="<?= e(url('resources/unshare')) ?>" id="resUnshareForm" data-res-idform hidden>
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="">
|
<input type="hidden" name="id" value="">
|
||||||
<input type="hidden" name="person_id" value="">
|
<input type="hidden" name="person_id" value="">
|
||||||
@@ -430,7 +430,7 @@ $myPerson = (int)\App\Auth::personId();
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php /* 删附件:唯一的一份表单,脚本填 id 后提交。 */ ?>
|
<?php /* 删附件:唯一的一份表单,脚本填 id 后提交。 */ ?>
|
||||||
<form method="post" action="<?= e(url('resources/attach_del')) ?>" id="resAttDelForm" hidden>
|
<form method="post" action="<?= e(url('resources/attach_del')) ?>" id="resAttDelForm" data-res-idform hidden>
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="">
|
<input type="hidden" name="id" value="">
|
||||||
</form>
|
</form>
|
||||||
@@ -586,6 +586,33 @@ $myPerson = (int)\App\Auth::personId();
|
|||||||
var panel = panels[which];
|
var panel = panels[which];
|
||||||
if (!panel) { return; }
|
if (!panel) { return; }
|
||||||
|
|
||||||
|
/* 🔴 v32 第 1 条真根因(用户:「弹框中,点击共享给某个人后,弹框消失,
|
||||||
|
但是并没有共享成功。」):
|
||||||
|
编辑面板现在是**三个页签**,每个页签有**自己的** `<form>` 和
|
||||||
|
**自己的** `<input name="id">`(info=resources/update、att=attach_add、
|
||||||
|
share=resources/share),面板末尾还有两个"唯一表单"(#resUnshareForm /
|
||||||
|
#resAttDelForm,也都带 name="id")。
|
||||||
|
而 v28 把三者并成一个面板时,这里写的是:
|
||||||
|
var idInp = panel.querySelector('input[name="id"]');
|
||||||
|
idInp.value = id; // ← 只填了**第一个**
|
||||||
|
`querySelector` 返回**文档序里第一个**(info 那个 / resources/update),
|
||||||
|
于是 att 与 **share** 两个页签的表单 `id` **一直是空** ⇒ 提交 `id=0` ⇒
|
||||||
|
后端 `Resource::find($familyId, 0, $me)` 返回 null ⇒ **静默不共享**
|
||||||
|
(只在列表页闪一句"这条资源不能共享。",用户多半没注意)。
|
||||||
|
⇒ 改成把 id 填进**这一屏里每一个会提交的表单**。
|
||||||
|
⚠️ 别写成"只填当前页签那份":用户会在弹框里切页签,
|
||||||
|
数据一次灌好、切页签就是纯显示切换(与上面注释同一取舍)。
|
||||||
|
⚠️⚠️ 判据用**显式标记**(`[data-res-idform]`),**不要**按 action 匹配:
|
||||||
|
`url()` 走 http_build_query,斜杠是 URL 编码的
|
||||||
|
(实测 `action="index.php?r=resources%2Fupdate"`),
|
||||||
|
写 `action*="resources/update"` 一条都匹配不上 —— 看着"改了",其实没改。 */
|
||||||
|
Array.prototype.forEach.call(
|
||||||
|
panel.querySelectorAll('form[data-res-idform]'),
|
||||||
|
function (f) {
|
||||||
|
var i = f.querySelector('input[name="id"]');
|
||||||
|
if (i) { i.value = id; }
|
||||||
|
});
|
||||||
|
|
||||||
// 每个面板都有的两样:标题回显 + 宿主 id
|
// 每个面板都有的两样:标题回显 + 宿主 id
|
||||||
var tt = panel.querySelector('.rd-title');
|
var tt = panel.querySelector('.rd-title');
|
||||||
if (tt) { tt.textContent = title; }
|
if (tt) { tt.textContent = title; }
|
||||||
|
|||||||
Reference in New Issue
Block a user