2026-10-19 12:51:17 +08:00
|
|
|
|
<?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];
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-19 17:27:37 +08:00
|
|
|
|
* 卡片注册表:key => [名字, 说明, 默认宽, 默认高, 详情页路由, 按钮文字, 是否有"显示几条", 来自哪个模块]
|
2026-10-19 12:51:17 +08:00
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ key 就是模板名(`_card_<key>.php`)与 `data-hc` 的值,三处必须一致。
|
|
|
|
|
|
* ⚠️ 新加一张卡片只改这里 + 写一个 `_card_xxx.php`:布局设置页、默认布局、
|
|
|
|
|
|
* 首页渲染全部自动出现(`HomeMods` 那条"新增模块的唯一入口"的同一条经验)。
|
2026-10-19 17:27:37 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v63(用户 2026-10-19 第 1 条):「卡片右上角的按钮,应该跟这个卡片来自于
|
|
|
|
|
|
* 什么模块的主题色一样才对。」⇒ 每张卡片登记它**来自哪个模块**(`mod`),
|
|
|
|
|
|
* 右上角按钮的底色就取那个模块的主题色(家庭级、可在设置里改,见 `Module::color`)。
|
|
|
|
|
|
* ⚠️ 之前只有日程/任务两张硬编码写死(`in_array($hcKey, ['agenda','tasks'])`),
|
|
|
|
|
|
* 那张白名单放在模板里 —— 新加一张带按钮的卡片就忘了加,按钮默默变灰。
|
|
|
|
|
|
* 现在登记在这里:**有 route 就该有 mod**,两件事一次改完。
|
|
|
|
|
|
* ⚠️ `mod` 只影响按钮配色,不参与权限/路由;认不出的 key 走 `Module::color` 的兜底。
|
2026-10-21 13:22:37 +08:00
|
|
|
|
* 🔴 v71c 补一条硬规矩(拿 v63 的 A1 换来的):
|
|
|
|
|
|
* **`route` 非空就必须有 `mod`**,而且 `mod` 必须是 `Module::LIST` 里的键
|
|
|
|
|
|
* (认不出归哪个模块就归 `home`)。
|
|
|
|
|
|
* 反例:通知卡片当初 `route='notifications'` 却把 `mod` 留成空串,
|
|
|
|
|
|
* 于是它成了整排按钮里**唯一白底 ghost** 的那个 ——
|
|
|
|
|
|
* 而 "通知" 本来就不在 `Module::LIST`,空串看着"很合理"。
|
|
|
|
|
|
* 判据不能靠自觉,A1 那条断言(带按钮的卡片都得有色)就是它的守卫。
|
2026-10-19 12:51:17 +08:00
|
|
|
|
*/
|
|
|
|
|
|
public const TYPES = [
|
|
|
|
|
|
'brief' => [
|
|
|
|
|
|
'name' => '概述', 'desc' => '问候、日期、农历、天气,以及今天的数字',
|
2026-10-19 17:27:37 +08:00
|
|
|
|
'w' => 8, 'h' => 2, 'route' => '', 'more' => '', 'n' => 0, 'mod' => '',
|
2026-10-19 12:51:17 +08:00
|
|
|
|
],
|
|
|
|
|
|
'agenda' => [
|
|
|
|
|
|
'name' => '今天的日程', 'desc' => '按日视图那样排的今天',
|
|
|
|
|
|
'w' => 4, 'h' => 8, 'route' => 'calendar', 'more' => '更多日程', 'n' => 0,
|
2026-10-19 17:27:37 +08:00
|
|
|
|
'mod' => 'calendar',
|
2026-10-19 12:51:17 +08:00
|
|
|
|
],
|
|
|
|
|
|
'tasks' => [
|
|
|
|
|
|
'name' => '近期任务', 'desc' => '进行中与待办(含看板与状态)',
|
|
|
|
|
|
'w' => 4, 'h' => 8, 'route' => 'tasks', 'more' => '全部任务', 'n' => 0,
|
2026-10-19 17:27:37 +08:00
|
|
|
|
'mod' => 'tasks',
|
2026-10-22 12:40:14 +08:00
|
|
|
|
/* 🔴 v78(用户 2026-10-22):「在全部任务旁边,放一个新建任务的功能,
|
|
|
|
|
|
其实就是把任务中的新建和编辑功能,搬过来,用弹出框来处理。」
|
|
|
|
|
|
⇒ 卡片右上角在这一张上多一颗按钮;它打开的是**与任务页同一张**弹框
|
|
|
|
|
|
(`templates/tasks/_add_modal.php`)。
|
|
|
|
|
|
⚠️ 登记在这里而不是在模板里写死:以后哪张卡要"新建"就加一行,
|
|
|
|
|
|
模板只负责按有没有这个字段决定画不画。 */
|
|
|
|
|
|
'add' => '新建任务',
|
2026-10-19 12:51:17 +08:00
|
|
|
|
],
|
2026-10-21 13:22:37 +08:00
|
|
|
|
/* 🔴 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',
|
|
|
|
|
|
],
|
2026-10-19 12:51:17 +08:00
|
|
|
|
'memories' => [
|
|
|
|
|
|
'name' => '往年的今天', 'desc' => '往年同一天的观影与旅程',
|
2026-10-19 17:27:37 +08:00
|
|
|
|
'w' => 4, 'h' => 4, 'route' => '', 'more' => '', 'n' => 0, 'mod' => '',
|
2026-10-19 12:51:17 +08:00
|
|
|
|
],
|
|
|
|
|
|
'days' => [
|
|
|
|
|
|
'name' => '纪念日', 'desc' => '接下来 60 天的纪念日与生日',
|
|
|
|
|
|
'w' => 4, 'h' => 4, 'route' => 'days', 'more' => '全部纪念日', 'n' => 0,
|
2026-10-19 17:27:37 +08:00
|
|
|
|
'mod' => 'days',
|
2026-10-22 22:05:17 +08:00
|
|
|
|
/* 🔴 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',
|
2026-10-19 12:51:17 +08:00
|
|
|
|
],
|
|
|
|
|
|
'movies' => [
|
|
|
|
|
|
'name' => '观影', 'desc' => '最近看过的片子',
|
|
|
|
|
|
'w' => 4, 'h' => 4, 'route' => 'movies', 'more' => '观影记录', 'n' => 0,
|
2026-10-19 17:27:37 +08:00
|
|
|
|
'mod' => 'movies',
|
2026-10-22 22:05:17 +08:00
|
|
|
|
/* 🔴 v81(同一条):「比如电影观影,它有一个"新增电影"的功能。」
|
|
|
|
|
|
⚠️ 文案按用户说的「新增电影」,但弹框标题仍是模块里的「记一场电影」——
|
|
|
|
|
|
不为了统一而改模块文案(那是模块自己的措辞,用户没要求动)。 */
|
|
|
|
|
|
'add' => '新增电影', 'add_modal' => 'mvAddCard',
|
2026-10-19 12:51:17 +08:00
|
|
|
|
],
|
|
|
|
|
|
'books' => [
|
|
|
|
|
|
'name' => '读书', 'desc' => '在读与最近加进书架的书',
|
|
|
|
|
|
'w' => 4, 'h' => 4, 'route' => 'books', 'more' => '书架', 'n' => 0,
|
2026-10-19 17:27:37 +08:00
|
|
|
|
'mod' => 'books',
|
2026-10-19 12:51:17 +08:00
|
|
|
|
],
|
|
|
|
|
|
'contacts' => [
|
|
|
|
|
|
'name' => '联系人', 'desc' => '名册里的人,可以选显示几个',
|
|
|
|
|
|
'w' => 4, 'h' => 4, 'route' => 'people', 'more' => '名册', 'n' => 8,
|
2026-10-19 17:27:37 +08:00
|
|
|
|
'mod' => 'people',
|
2026-10-19 12:51:17 +08:00
|
|
|
|
],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-10-19 17:27:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 这张卡片来自哪个模块(没有归属的返回空串 —— 模板据此决定按钮用不用主题色)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 别在模板里再写一份白名单:`mod` 是唯一来源。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function module(string $key): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return (string)(self::TYPES[$key]['mod'] ?? '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 12:51:17 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 默认布局(第一次打开首页的人看到的样子)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 排布理由:概述一条横贯(h=2,正好是一行信息);日程与任务各 4 宽、8 高
|
|
|
|
|
|
* ——它们是"今天要做什么",给足高度;下面 4 宽的几张是"翻一翻"的内容。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public const DEFAULT_LAYOUT = [
|
|
|
|
|
|
['k' => 'brief', 'w' => 8, 'h' => 2],
|
2026-10-21 13:22:37 +08:00
|
|
|
|
/* 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],
|
2026-10-19 12:51:17 +08:00
|
|
|
|
['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) {
|
2026-10-21 13:22:37 +08:00
|
|
|
|
case 'notif':
|
|
|
|
|
|
$out['notif'] = self::notifData($me);
|
|
|
|
|
|
break;
|
2026-10-19 12:51:17 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 13:22:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 通知卡片(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),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 12:51:17 +08:00
|
|
|
|
/** 接下来 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];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|