[名字, 说明, 默认宽, 默认高, 详情页路由, 按钮文字, 是否有"显示几条", 来自哪个模块] * * ⚠️ key 就是模板名(`_card_.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` 的兜底。 */ 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', ], 'tasks' => [ 'name' => '近期任务', 'desc' => '进行中与待办(含看板与状态)', 'w' => 4, 'h' => 8, 'route' => 'tasks', 'more' => '全部任务', 'n' => 0, 'mod' => 'tasks', ], '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', ], 'movies' => [ 'name' => '观影', 'desc' => '最近看过的片子', 'w' => 4, 'h' => 4, 'route' => 'movies', 'more' => '观影记录', 'n' => 0, 'mod' => 'movies', ], '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', ], ]; /** * 这张卡片来自哪个模块(没有归属的返回空串 —— 模板据此决定按钮用不用主题色)。 * * ⚠️ 别在模板里再写一份白名单:`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], ['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 */ 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 */ 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 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]; } }