Files
laoyang 4b58fec796 v82: 首页卡片补「新建日程 / 新增成员」+ 两处红线
用户原话:「日程,没有新建日程按钮,名册,也没有新增成员这个按钮。
另外,首页上的日程,红色线不对,变短了左侧缺了一块。在日程模块中,
那个红线左侧是不是还有一个红点?如果有,去掉。」

首页卡片就地操作(不用进模块):
- HomeCards.php 的 agenda/contacts 补 add + add_modal 元数据
- templates/calendar/_add_modal.php:从 calendar/index.php 抽出的
  「新建日程」弹框片段(首页与日程页共用;片段自读变量一律加 cal 前缀,
  避免 require 共享作用域覆盖宿主变量)
- templates/people/form.php:加弹框形态(pfModalOnly / pfModal),
  弹框形态下「取消」走 data-fb-add-close
- index.php:首页 agenda/contacts 两个 $homeAddModals 分支;
  calendar/event_add 支持 back=home 白名单跳转(不采信任意 POST 字符串)

红线(左端缺一块 / 多余红点):
- .ag-now 原来写 left: var(--ag-gutter,50px),但 v80 起它已经在
  .ag-lines 内部(那层自己已有同样缩进)⇒ 左沿跑到 100px、比车道多
  出一整份刻度宽。改成 left: 0
- .tg-now::before 整条删除(日程模块页那个 8px 红点)

涉及共用件(HomeCards/index.php),已跑全量:e2e 1572/0、
v82 正向 20/0、v82 反向 15/0、流水线全绿并已部署。
2026-10-24 04:25:02 +08:00

441 lines
22 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 首页卡片体系(v62 用户 2026-10-18 第 3 条)。
*
* 用户原话:「今天,我们正式引入卡片的概念,在首页上,概述是卡片,日程是卡片,
* 近期任务是卡片,往年的今天也是卡片,纪念日,要有一个卡片,电影、日程、
* 读书,都要有自己的卡片。例如,联系人,可以有卡片,让用户选择显示几个
* 联系人在首页上,卡片的规格,我们可以定义一下,例如,把宽度定义为 8,
* 高度可以是 2、4、6、8,这样。如果是 2x2,那么就可以一排放 4 个,
* 如果是 2x8,那就是一行放一个,类似于这样的定义。」
*
* ────────────────────────────────────────────────────────────────
* 规格口径(**先说清楚,后面所有尺寸判断都用这一套**):
* · 一排 **8 格**(`COLS = 8`)。卡片的"宽"就是占几格:2 / 4 / 6 / 8。
* w=2 → 一排 4 张;w=4 → 一排 2 张;w=8 → 独占一排。
* (用户举的"2x2 一排放 4 个"就是这个意思:宽 2、高 2。)
* · 卡片的"高"是**行数**:2 / 4 / 6 / 8。CSS 里一行 76px、间距 10px ——
* 所以 h=2 是"一块信息条",h=8 是"能滚一屏的清单"。
* · 写法统一是 **宽 × 高**(`2×4` = 占 2 格宽、4 行高)。用户在界面上看到的
* 下拉也是"宽 2 / 宽 4…"与"高 2 / 高 4…",不写 `2x8` 这种容易搞反的记法。
*
* 三条纪律(与 HomeMods / Module::navFor 同一套,踩过的坑不再踩):
* ① 存 `user_settings('home_cards')` —— 首页怎么摆是**每个人自己**的事;
* ② 读的时候只留"现在还存在的卡片"(删掉一种卡片之后,老配置里那条认不出来);
* ③ 另存 `home_cards_known` =「做这次决定时世上已知的全部卡片」——
* 只有"现在的卡片 - known"才是真的新加、才补到末尾。**不能**写成
* "不在已选里就补":那样用户主动关掉的卡片下次刷新就自己回来了。
*
* ⚠️ 卡片只负责**取数 + 声明默认尺寸**,渲染在 `templates/home/_card_*.php`。
* 不要在这里拼 HTML —— 模板层才能跟其它页面的片断复用同一套样式。
*/
declare(strict_types=1);
namespace App;
final class HomeCards
{
/** 一排几格(宽度单位) */
public const COLS = 8;
/** 允许的宽度(格)与高度(行) */
public const WIDTHS = [2, 4, 6, 8];
public const HEIGHTS = [2, 4, 6, 8];
/**
* 卡片注册表:key => [名字, 说明, 默认宽, 默认高, 详情页路由, 按钮文字, 是否有"显示几条", 来自哪个模块]
*
* ⚠️ key 就是模板名(`_card_<key>.php`)与 `data-hc` 的值,三处必须一致。
* ⚠️ 新加一张卡片只改这里 + 写一个 `_card_xxx.php`:布局设置页、默认布局、
* 首页渲染全部自动出现(`HomeMods` 那条"新增模块的唯一入口"的同一条经验)。
*
* 🔴 v63(用户 2026-10-19 第 1 条):「卡片右上角的按钮,应该跟这个卡片来自于
* 什么模块的主题色一样才对。」⇒ 每张卡片登记它**来自哪个模块**(`mod`),
* 右上角按钮的底色就取那个模块的主题色(家庭级、可在设置里改,见 `Module::color`)。
* ⚠️ 之前只有日程/任务两张硬编码写死(`in_array($hcKey, ['agenda','tasks'])`),
* 那张白名单放在模板里 —— 新加一张带按钮的卡片就忘了加,按钮默默变灰。
* 现在登记在这里:**有 route 就该有 mod**,两件事一次改完。
* ⚠️ `mod` 只影响按钮配色,不参与权限/路由;认不出的 key 走 `Module::color` 的兜底。
* 🔴 v71c 补一条硬规矩(拿 v63 的 A1 换来的):
* **`route` 非空就必须有 `mod`**,而且 `mod` 必须是 `Module::LIST` 里的键
* (认不出归哪个模块就归 `home`)。
* 反例:通知卡片当初 `route='notifications'` 却把 `mod` 留成空串,
* 于是它成了整排按钮里**唯一白底 ghost** 的那个 ——
* 而 "通知" 本来就不在 `Module::LIST`,空串看着"很合理"。
* 判据不能靠自觉,A1 那条断言(带按钮的卡片都得有色)就是它的守卫。
*/
public const TYPES = [
'brief' => [
'name' => '概述', 'desc' => '问候、日期、农历、天气,以及今天的数字',
'w' => 8, 'h' => 2, 'route' => '', 'more' => '', 'n' => 0, 'mod' => '',
],
'agenda' => [
'name' => '今天的日程', 'desc' => '按日视图那样排的今天',
'w' => 4, 'h' => 8, 'route' => 'calendar', 'more' => '更多日程', 'n' => 0,
'mod' => 'calendar',
/* 🔴 v82(用户 2026-10-23):「日程,没有新建日程按钮……」
⚠️ 实测确认:他说的是**这张卡片**上没按钮 —— 日程模块页右上角
那颗 `#calAddBtn` 一直在、也可见(`l=1042, w=90`,点得到)。
⇒ 这里补上 `add`,卡片右上角就多一颗「新建日程」。
⚠️ `add_modal` 的 id 必须与 `templates/calendar/_add_modal.php` 里的
容器 id **逐字相同**(`calAddCard`)——写错的后果是"点了没反应",
是本项目最难查的一类 bug(v81 真事故:`anAddCard` vs `anNewCard`)。 */
'add' => '新建日程',
'add_modal' => 'calAddCard',
],
'tasks' => [
'name' => '近期任务', 'desc' => '进行中与待办(含看板与状态)',
'w' => 4, 'h' => 8, 'route' => 'tasks', 'more' => '全部任务', 'n' => 0,
'mod' => 'tasks',
/* 🔴 v78(用户 2026-10-22):「在全部任务旁边,放一个新建任务的功能,
其实就是把任务中的新建和编辑功能,搬过来,用弹出框来处理。」
⇒ 卡片右上角在这一张上多一颗按钮;它打开的是**与任务页同一张**弹框
(`templates/tasks/_add_modal.php`)。
⚠️ 登记在这里而不是在模板里写死:以后哪张卡要"新建"就加一行,
模板只负责按有没有这个字段决定画不画。 */
'add' => '新建任务',
],
/* 🔴 v71c(用户 2026-10-21):「通知消息这个,也是首页的卡片,可以调整顺序和大小。」
⇒ 它从"网格外的写死区块"变成**正规卡片**:可拖顺序、可调宽高、可关掉。
🔴 `mod` 给 **`home`**,不是空串:
通知**不是** `Module::LIST` 里的模块(见 Notification 头部第 ①条),
但它是**首页自己**的一张卡片 ⇒ 右上角「全部通知」按钮用**首页的主题色**。
早先留空串(想让它走 `btn-ghost`)被 v63 的 A1 当场抓住:
「带按钮的卡片都得有模块色」—— 空串会让它变成白底 ghost,
在一排同色的模块按钮里**唯独它不一样**,那正是 v63 要消灭的形态。
⇒ 规矩落地成:**`route` 非空就必须有 `mod`**,认不出归哪个模块就归 `home`。 */
'notif' => [
'name' => '通知消息', 'desc' => '谁把你加进了哪件事(与通知页同一套样子)',
'w' => 4, 'h' => 4, 'route' => 'notifications', 'more' => '全部通知', 'n' => 0,
'mod' => 'home',
],
'memories' => [
'name' => '往年的今天', 'desc' => '往年同一天的观影与旅程',
'w' => 4, 'h' => 4, 'route' => '', 'more' => '', 'n' => 0, 'mod' => '',
],
'days' => [
'name' => '纪念日', 'desc' => '接下来 60 天的纪念日与生日',
'w' => 4, 'h' => 4, 'route' => 'days', 'more' => '全部纪念日', 'n' => 0,
'mod' => 'days',
/* 🔴 v81(用户 2026-10-22):「如果是纪念日,就有一个"新增纪念日"。」
⇒ 首页卡片右上角直接开新建弹框(与纪念日页**同一张** `days/form.php`)。
`add_modal` = 那张弹框在首页里的容器 id,`home.php` 拼成
`data-fb-add-open="#anNewCard"`;掉进 `assets/js/add_modal.js` 的通用逻辑。
🔴 这个 id 必须与 `days/form.php` 里真实的容器 id **逐字一致**:
第一版我按"an + AddCard"的规律写成 `anAddCard`,而实际是 `anNewCard`
⇒ 按钮指向了一个不存在的元素 ⇒ **点下去毫无反应**(探针⑥当场抓到)。
这正是"点了没反应最难查"的那一类:属性在、DOM 也没报错。 */
'add' => '新建纪念日', 'add_modal' => 'anNewCard',
],
'movies' => [
'name' => '观影', 'desc' => '最近看过的片子',
'w' => 4, 'h' => 4, 'route' => 'movies', 'more' => '观影记录', 'n' => 0,
'mod' => 'movies',
/* 🔴 v81(同一条):「比如电影观影,它有一个"新增电影"的功能。」
⚠️ 文案按用户说的「新增电影」,但弹框标题仍是模块里的「记一场电影」——
不为了统一而改模块文案(那是模块自己的措辞,用户没要求动)。 */
'add' => '新增电影', 'add_modal' => 'mvAddCard',
],
'books' => [
'name' => '读书', 'desc' => '在读与最近加进书架的书',
'w' => 4, 'h' => 4, 'route' => 'books', 'more' => '书架', 'n' => 0,
'mod' => 'books',
],
'contacts' => [
'name' => '联系人', 'desc' => '名册里的人,可以选显示几个',
'w' => 4, 'h' => 4, 'route' => 'people', 'more' => '名册', 'n' => 8,
'mod' => 'people',
/* 🔴 v82(用户 2026-10-23):「名册,也没有新增成员这个按钮。」
⚠️ 实测确认:名册模块页那颗「添加成员」**一直在**
(`<a class="btn btn-sm" href="people/new">`,可见可点)。
用户说的是**这张联系人卡片**上没有按钮。
⇒ 补 `add`,卡片右上角多一颗「新增成员」,点开的是**与名册页同一张**
表单(`templates/people/form.php`,加了弹框形态)。
⚠️ `add_modal` 必须与 `people/form.php` 里那个容器 id 逐字相同
(`pfAddCard`)。写错 = 点了没反应(v81 真事故:anAddCard vs anNewCard)。 */
'add' => '新增成员',
'add_modal' => 'pfAddCard',
],
];
/**
* 这张卡片来自哪个模块(没有归属的返回空串 —— 模板据此决定按钮用不用主题色)。
*
* ⚠️ 别在模板里再写一份白名单:`mod` 是唯一来源。
*/
public static function module(string $key): string
{
return (string)(self::TYPES[$key]['mod'] ?? '');
}
/**
* 默认布局(第一次打开首页的人看到的样子)。
*
* 排布理由:概述一条横贯(h=2,正好是一行信息);日程与任务各 4 宽、8 高
* ——它们是"今天要做什么",给足高度;下面 4 宽的几张是"翻一翻"的内容。
*/
public const DEFAULT_LAYOUT = [
['k' => 'brief', 'w' => 8, 'h' => 2],
/* v71c(用户 2026-10-21):通知卡片钉在**概述下面第一张**。两个约束同时满足:
① v30 第 5 条"通知出现在首页,往年的今天前面" —— 默认摆位就满足;
② 日程与任务必须**并排**(都是 w4/h8,是"今天要做什么"的一对)——
所以通知占 4 宽、与另一张 4 宽的(纪念日)并成一行,**不插在它们中间**。
早先直接写在 brief 与 agenda 之间、宽 4,则 agenda/tasks 被挤成
一上一下(实测 C13:顶边 407 vs 751)—— 那是纯视觉回归,必须在默认值里避免。
⚠️ 用户把它拖到别处、改成别的尺寸都允许,这里只是"第一次打开看到的样子"。 */
['k' => 'notif', 'w' => 4, 'h' => 4],
['k' => 'days', 'w' => 4, 'h' => 4],
['k' => 'agenda', 'w' => 4, 'h' => 8],
['k' => 'tasks', 'w' => 4, 'h' => 8],
['k' => 'memories', 'w' => 4, 'h' => 4],
['k' => 'movies', 'w' => 4, 'h' => 4],
['k' => 'books', 'w' => 4, 'h' => 4],
['k' => 'contacts', 'w' => 4, 'h' => 4],
];
public static function exists(string $key): bool
{
return isset(self::TYPES[$key]);
}
public static function name(string $key): string
{
return self::TYPES[$key]['name'] ?? '';
}
/** 「显示几条」这类数字的上限:卡片再高也放不下更多 */
public const N_MAX = 30;
/**
* 这个人首页上要摆哪些卡片、各自的尺寸(按顺序)。
*
* @return array<int,array{k:string,w:int,h:int,n:int}>
*/
public static function layout(int $personId): array
{
$raw = (string)UserSetting::get($personId, 'home_cards', '');
$dec = $raw !== '' ? json_decode($raw, true) : null;
if (!is_array($dec)) {
return self::normalize(self::DEFAULT_LAYOUT);
}
$saved = self::normalize($dec);
if (!$saved) {
// 用户把卡片全关了 —— 这是合法状态(他就想要一个空首页),不兜回默认。
// ⚠️ 但"真的全关"与"存坏了"在数据上分不出来;判据是**原始串非空**,
// 所以这里只在 $raw 非空时走到,属于前者。
return [];
}
// ③ 真的新加的卡片补到末尾
$known = [];
$kraw = (string)UserSetting::get($personId, 'home_cards_known', '');
$kdec = $kraw !== '' ? json_decode($kraw, true) : null;
if (is_array($kdec)) {
foreach ($kdec as $k) {
$known[(string)$k] = true;
}
}
$have = array_column($saved, 'k');
foreach (self::TYPES as $k => $def) {
if (!isset($known[$k]) && !in_array($k, $have, true)) {
$saved[] = [
'k' => $k, 'w' => (int)$def['w'], 'h' => (int)$def['h'],
'n' => (int)$def['n'],
];
}
}
return $saved;
}
/**
* 把任意输入洗成合法布局(去重、宽度/高度夹到允许值、n 夹到 1..30)。
*
* @param array $items 元素可以是 ['k'=>..,'w'=>..,'h'=>..,'n'=>..]
* @return array<int,array{k:string,w:int,h:int,n:int}>
*/
public static function normalize(array $items): array
{
$out = [];
$seen = [];
foreach ($items as $it) {
if (!is_array($it)) {
continue;
}
$k = (string)($it['k'] ?? '');
if (!self::exists($k) || isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$w = (int)($it['w'] ?? self::TYPES[$k]['w']);
$h = (int)($it['h'] ?? self::TYPES[$k]['h']);
$n = (int)($it['n'] ?? self::TYPES[$k]['n']);
$out[] = [
'k' => $k,
'w' => in_array($w, self::WIDTHS, true) ? $w : (int)self::TYPES[$k]['w'],
'h' => in_array($h, self::HEIGHTS, true) ? $h : (int)self::TYPES[$k]['h'],
'n' => max(1, min(self::N_MAX, $n > 0 ? $n : 1)),
];
}
return $out;
}
/** 保存(顺序即数组顺序;不在数组里的就是关掉的) */
public static function save(int $personId, array $items): void
{
$out = self::normalize($items);
UserSetting::set($personId, 'home_cards', json_encode($out, JSON_UNESCAPED_UNICODE));
UserSetting::set($personId, 'home_cards_known',
json_encode(array_keys(self::TYPES), JSON_UNESCAPED_UNICODE));
}
/**
* 设置页要用的展示顺序:已开的按现有顺序在前,没开的按注册表顺序在后。
* 每一项都带上"当前尺寸"(没开的用它自己的默认值,勾上就是那个尺寸)。
*/
public static function orderedForPicker(int $personId): array
{
$on = self::layout($personId);
$rows = [];
foreach ($on as $c) {
$rows[] = $c + ['on' => true];
}
$have = array_column($on, 'k');
foreach (self::TYPES as $k => $def) {
if (in_array($k, $have, true)) {
continue;
}
$rows[] = [
'k' => $k, 'w' => (int)$def['w'], 'h' => (int)$def['h'],
'n' => (int)$def['n'], 'on' => false,
];
}
return $rows;
}
// ==================== 取数 ====================
/**
* 按布局取数(**只取用到的**:用户关掉的卡片不该白查一次库)。
*
* brief / agenda / tasks / memories 的数据由首页路由本来就算好了
* (今天日程、近期任务、往年的今天都是"今天"这一屏的核心数据),
* 这里只补那几张"各自一块小查询"的卡片 —— 同一份数据不重复算。
*
* @param array $layout self::layout() 的返回值
* @return array<string,mixed> key => 数据
*/
public static function data(array $layout, int $familyId, int $me): array
{
$out = [];
foreach ($layout as $c) {
$k = (string)$c['k'];
switch ($k) {
case 'notif':
$out['notif'] = self::notifData($me);
break;
case 'days':
$out['days'] = self::daysData($familyId, $me);
break;
case 'movies':
$out['movies'] = self::moviesData($familyId);
break;
case 'books':
$out['books'] = self::booksData($familyId);
break;
case 'contacts':
$out['contacts'] = self::contactsData($familyId, $me, (int)($c['n'] ?? 8));
break;
default:
break;
}
}
return $out;
}
/**
* 通知卡片(v71c 用户 2026-10-21)。
*
* 用户原话:「通知消息这个,也是首页的卡片,可以调整顺序和大小。」
* ⇒ 它原来在 `templates/home.php` 里是一块**网格外的写死区块**(v30 第 5 条那会儿
* 首页还没有卡片概念),只有"摆不摆"没有"摆哪儿"。现在是卡片:位置与尺寸
* 都由 `home_cards` 决定。
*
* ⚠️ 取数的口径与原来**一字不差**:最新 10 条 + 未读数。首页是"瞥一眼"的地方,
* 真正的消息中心在 `?r=notifications`(那一页才 markAllRead)。
* ⚠️ 关掉卡片 = 不查这两条 SQL(`data()` 只对用到的 key 取数)。
*
* @return array{rows:array,unread:int}
*/
public static function notifData(int $me, int $limit = 10): array
{
return [
'rows' => Notification::mine($me, max(1, min(self::N_MAX, $limit))),
'unread' => Notification::unreadCount($me),
];
}
/** 接下来 60 天的纪念日(按"还有几天"升序,最多 12 条 —— 卡片就那么大) */
public static function daysData(int $familyId, int $me): array
{
$rows = Anniversary::upcoming($familyId, $me, 60);
return array_slice($rows, 0, 12);
}
/**
* 最近看过的片子(8 条)。
*
* ⚠️ 不给 `Movie::all()` 加参数而是这里自己查:all() 会顺带
* attachAttendees + review 统计(两次额外查询),首页卡片上根本不显示这些。
*/
public static function moviesData(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT e.id, e.title, e.starts_at, d.poster_key, c.name AS cinema_name
FROM events e
LEFT JOIN movie_details d ON d.event_id = e.id
LEFT JOIN cinemas c ON c.id = d.cinema_id
WHERE e.family_id = ? AND e.kind = "movie"
ORDER BY e.starts_at DESC, e.id DESC
LIMIT 8'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
/**
* 书架(在读优先,最多 6 本)。
*
* ⚠️ 排序用 `FIELD(status,...)` 而不是"先在 SQL 里筛 reading 再 UNION 其余" ——
* 一条语句、一个索引扫描,读起来也不会分成两段逻辑。
*/
public static function booksData(int $familyId): array
{
$st = Database::get()->prepare(
"SELECT id, title, author, status, progress, chapter_count, cover_key
FROM books
WHERE family_id = ?
ORDER BY FIELD(status, 'reading', 'wish', 'paused', 'done'), id DESC
LIMIT 6"
);
$st->execute([$familyId]);
return $st->fetchAll();
}
/**
* 联系人卡片:只取"我名册里的人",前面 n 个。
*
* 用户原话:「联系人,可以有卡片,让用户选择显示几个联系人在首页上。」
* ⚠️ 用 `Person::roster()` 而不是 `Person::map()`:名册是**每人一份**的
* (我加的人 + 别人分享给我的),首页是"我的首页",不该把别人名册里的人
* 也铺上来。`Person::map()` 会绕过这个边界。
*
* @return array{rows:array,total:int}
*/
public static function contactsData(int $familyId, int $me, int $n): array
{
$people = Person::roster($familyId, $me, 'member', '');
$total = count($people);
$n = max(1, min(self::N_MAX, $n));
return ['rows' => array_slice($people, 0, $n), 'total' => $total];
}
}