Files
FamilyBook/app/HomeCards.php
T
laoyang 9a66068c33 v62:纪念日列表修正 / 分类图标与设置菜单 / 首页卡片体系
- 纪念日列表:display:flex 从 td 移到内层 div(写在 td 上会破坏表格列算法,
  表现为关联人列与其它列高度不一致);行内列序按表头对齐;
  改完再点「新建」不再带出上次修改的数据;文案改为「与我相关」「来自名册」;
  别人记的、把我挂成关联人的条目一并显示(split() 返回 hiddenIds,
  路由层据此过滤「接下来的日子」,与两张表同一套可见性)
- 分类图标:新增 app/AnnivIcon.php(17 个行内 SVG 图标 + 同 key 的 emoji
  供日历纯文本处使用,内网无 CDN 不用 FontAwesome);去掉「看日历」按钮;
  设置按钮改为菜单,第一项进 days/settings 独立页;分类可自选图标,
  列表徽标与日历条目都按分类显示
- 首页卡片:新增 app/HomeCards.php 与 templates/home_cards.php;
  8 格宽 × 高 2/4/6/8;首页拆成 templates/home/_card_*.php 共 8 个片段;
  home/cards 可改尺寸、调顺序、开关
2026-10-19 12:51:17 +08:00

324 lines
14 KiB
PHP
Raw 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` 那条"新增模块的唯一入口"的同一条经验)。
*/
public const TYPES = [
'brief' => [
'name' => '概述', 'desc' => '问候、日期、农历、天气,以及今天的数字',
'w' => 8, 'h' => 2, 'route' => '', 'more' => '', 'n' => 0,
],
'agenda' => [
'name' => '今天的日程', 'desc' => '按日视图那样排的今天',
'w' => 4, 'h' => 8, 'route' => 'calendar', 'more' => '更多日程', 'n' => 0,
],
'tasks' => [
'name' => '近期任务', 'desc' => '进行中与待办(含看板与状态)',
'w' => 4, 'h' => 8, 'route' => 'tasks', 'more' => '全部任务', 'n' => 0,
],
'memories' => [
'name' => '往年的今天', 'desc' => '往年同一天的观影与旅程',
'w' => 4, 'h' => 4, 'route' => '', 'more' => '', 'n' => 0,
],
'days' => [
'name' => '纪念日', 'desc' => '接下来 60 天的纪念日与生日',
'w' => 4, 'h' => 4, 'route' => 'days', 'more' => '全部纪念日', 'n' => 0,
],
'movies' => [
'name' => '观影', 'desc' => '最近看过的片子',
'w' => 4, 'h' => 4, 'route' => 'movies', 'more' => '观影记录', 'n' => 0,
],
'books' => [
'name' => '读书', 'desc' => '在读与最近加进书架的书',
'w' => 4, 'h' => 4, 'route' => 'books', 'more' => '书架', 'n' => 0,
],
'contacts' => [
'name' => '联系人', 'desc' => '名册里的人,可以选显示几个',
'w' => 4, 'h' => 4, 'route' => 'people', 'more' => '名册', 'n' => 8,
],
];
/**
* 默认布局(第一次打开首页的人看到的样子)。
*
* 排布理由:概述一条横贯(h=2,正好是一行信息);日程与任务各 4 宽、8 高
* ——它们是"今天要做什么",给足高度;下面 4 宽的几张是"翻一翻"的内容。
*/
public const DEFAULT_LAYOUT = [
['k' => 'brief', 'w' => 8, 'h' => 2],
['k' => 'agenda', 'w' => 4, 'h' => 8],
['k' => 'tasks', 'w' => 4, 'h' => 8],
['k' => 'days', 'w' => 4, 'h' => 4],
['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 '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;
}
/** 接下来 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];
}
}