Files
FamilyBook/app/HomeCards.php
T
laoyang 2f7dc6ca23 v63:卡片配色 / 日历本靠右 / 地图题图 / 设置页清理 / 公共标签颜色 / 令牌下线
- 卡片右上角按钮改用「来自哪个模块」的主题色:HomeCards 登记表新增 mod 字段
  与 module(),首页从登记表取色(原来是模板里硬编码 in_array(['agenda','tasks'])
  白名单,新加带按钮的卡片会默默变灰)
- 首页日程把日历本放到色块右侧:.ag-ev 改弹性行,「时间+标题」包进
  .ag-ev-main(flex:1 1 auto; min-width:0),日历本作右侧兄弟。
  原来 display:block 会把日历本顶到第二行,色块一矮就完全看不见
- 旅程地图显示题图:服务端把 media_url(cover_key) 塞进地图数据,
  地图卡片左侧 40x40 题图(object-fit:cover),没有题图不留灰框
- 系统设置第一块改叫「系统标语」;删掉「首页上那一行小字」提示、
  「不是全家的统一口号」说明、「上次结构升级有步骤失败了」整框;
  运行环境删「排错时看这一块」与「亲属连线」那一行(功能与数据未动)
- 公共标签:说明文字全删;列表与编辑合并成一行(色块+名字+N人+箭头+编辑);
  person_groups 加 color 列(Schema addColumn,老库可升);点「编辑」弹框
  改名字与颜色、点「完成」提交;下面那个独立编辑列表撤掉。删除挪进弹框
- 个人设置删掉「手动签发接口令牌」整块;settings/api_token 路由保留但挡下提交
  (只删界面会留下知道地址就能用的后门);令牌只由登录后自动签发,
  设备管理卡保留。apiTokens/apiPlain/apiLabel/apiBase 四个变量一并撤掉
2026-10-19 17:27:37 +08:00

348 lines
15 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` 那条"新增模块的唯一入口"的同一条经验)。
*
* 🔴 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<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];
}
}