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 可改尺寸、调顺序、开关
This commit is contained in:
2026-10-19 12:51:17 +08:00
parent ad7f43af68
commit 9a66068c33
21 changed files with 2172 additions and 685 deletions
+175 -20
View File
@@ -38,6 +38,23 @@ final class Anniversary
/** 庆典类纪念日大致的分类,给个默认值方便挑选(用户可自由填) */
public const PRESET_KINDS = ['生日', '结婚纪念日', '相识纪念日', '忌日', '节日', '其它'];
/**
* 预置分类各配哪个图标(v62)。
*
* ⚠️ 这张表只用于**兜底**:分类名 → 图标真正的来源是 settings 里那份
* `anniv_kinds`(用户可以在「分类设置」里逐条改)。这里的作用是
* 「老库升级上来、还没配过图标」时,六个预置分类不至于全变成同一个圆点。
* ⚠️ 值必须是 AnnivIcon 里存在的 key;认不出的会被 normalize 成 dot。
*/
public const PRESET_ICONS = [
'生日' => 'cake',
'结婚纪念日' => 'ring',
'相识纪念日' => 'heart',
'忌日' => 'candle',
'节日' => 'star',
'其它' => 'dot',
];
public const ATTACH = 'anniversary';
// ---------------- ① 用户自建的纪念日 ----------------
@@ -309,12 +326,24 @@ final class Anniversary
* 用户原话:「纪念日中,手动添加的纪念日和来自其他模块的,例如生日的纪念日,
* 要分开,手动纪念日在上面。」
*
* 所以返回 ['custom' => [...], 'derived' => [...]] 两块,各按日期排。
* 所以返回 ['custom' => [...], 'derived' => [...]], 'hidden' => N 三块,各按日期排。
* ⚠️ 派生块用**这个人的出生年**来算"今年是几号"没法排序(农历要逐年换算),
* 这里统一按 month/day 排(也就是"一年里按日历顺序"),
* 与列表上显示的「X 月 X 日」完全一致 —— 不会出现"排出来的顺序和看到的不一样"。
*
* @return array{custom:array,derived:array}
* 🔴 v62(用户 2026-10-18 第 1 条):第一块从「我自己添加的」改叫「与我相关」,
* 原话:「这里要注意,如果是别人分享给我的,就是我是关联人,这里应该也要显示。」
* ⇒ 判据(**满足一条就留下**):
* ① 这条纪念日没填关联人 —— 它是"全家的事"(结婚纪念日、家庭节日…),
* 谁添加的都该看得到;
* ② 我是它的关联人之一 —— 别人添加的、把我挂上了,就该出现在我这儿。
* 反过来:别人给他自己家亲戚记的忌日(关联人只有那位亲戚)**不进我的列表** ——
* 那正是"与我无关"。这类条数放回 `hidden` 里,模板上给一句小字说明,
* 免得用户以为"数据丢了"(它们在日历里照旧出现)。
*
* ⚠️ 派生块(名册生日)**不过这个筛**:生日本来就已经是"与我相关"的结果集。
*
* @return array{custom:array,derived:array,hidden:int,hiddenIds:array}
*/
public static function split(int $familyId, int $viewerId): array
{
@@ -322,6 +351,10 @@ final class Anniversary
// 一次把所有人的关联人查出来(避免逐行查库)——v24 第 25 批 用户第 2 条
$pmap = self::peopleMap(array_column($rows, 'id'));
$custom = [];
$hidden = 0;
/* 被筛掉的 id:页面上方那块「接下来的日子」也要跟着筛,否则出现
"上面列出来一条、下面两张表都找不到它"的自相矛盾(v62 实测踩到)。 */
$hiddenIds = [];
foreach ($rows as $r) {
$r['source'] = 'custom';
$r['person_name'] = '';
@@ -334,6 +367,13 @@ final class Anniversary
} else {
$r['person_ids'] = [];
}
// 见上面 ①②:只有"明确挂了别人、且没挂我"的才排除
if ($r['person_ids'] && $viewerId > 0
&& !in_array((int)$viewerId, array_map('intval', $r['person_ids']), true)) {
$hidden++;
$hiddenIds[] = $aid;
continue;
}
$custom[] = $r;
}
$derived = self::birthdays($familyId, $viewerId);
@@ -350,54 +390,166 @@ final class Anniversary
};
usort($custom, $byMD);
usort($derived, $byMD);
return ['custom' => $custom, 'derived' => $derived];
return ['custom' => $custom, 'derived' => $derived, 'hidden' => $hidden,
'hiddenIds' => $hiddenIds];
}
// ---------------- 纪念日分类(v24 用户 2026-10-03 第 9 条)----------------
/**
* 用户自定义的分类清单(family 级,存 settings)。
* 用户自定义的分类清单(family 级,存 settings)—— **带图标**(v62)。
*
* 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」
* v62 追加:「每一类纪念日都有不同的图标……如果用户添加了自己的分类,就让用户自己选。」
*
* ⚠️ 与分类**落到每条纪念日上的 kind 字段**是两件事:
* · 这里的清单 = 新建时下拉里给哪些选项(可增删);
* · 这里的清单 = 新建时下拉里给哪些选项(可增删、可换图标);
* · kind = 这一条实际选了哪个。
* 删掉一个分类**不**会把已用的纪念日改掉(那些 kind 仍然是那个字),
* 只是新建时不再出现在下拉里 —— 强改历史数据比留着一个"没人认识的分类"更糟。
*
* @return string[] 有序、去重、非空
* ⚠️ 存储形状变过一次(**必须兼容老数据**):
* v24 起:`["生日","结婚纪念日"]` —— 纯字符串数组
* v62 起:`[{"n":"生日","i":"cake"}, ...]` —— 名称 + 图标
* 读到字符串就按"没配图标"处理(用 PRESET_ICONS / 名字猜一个),
* 读到对象就取 n/i。**不改老库、不做一次性迁移**:读的时候兼容,写的时候
* 自然升级 —— 迁移脚本在"用户还没打开过设置页"时没有任何收益。
*
* @return array<int,array{name:string,icon:string}> 有序、去重、非空
*/
public static function kinds(int $familyId): array
public static function kindsFull(int $familyId): array
{
$raw = (string)Setting::get($familyId, 'anniv_kinds', '');
$dec = $raw !== '' ? json_decode($raw, true) : null;
if (!is_array($dec)) {
return self::PRESET_KINDS; // 没配过 → 用预置那套
}
$out = [];
foreach ($dec as $k) {
$k = trim((string)$k);
if ($k !== '' && mb_strlen($k, 'UTF-8') <= 20 && !in_array($k, $out, true)) {
$out[] = $k;
// 没配过 → 用预置那套(每个都配好图标)
$out = [];
foreach (self::PRESET_KINDS as $k) {
$out[] = ['name' => $k, 'icon' => self::defaultIcon($k)];
}
return $out;
}
$out = [];
$seen = [];
foreach ($dec as $item) {
if (is_array($item)) {
$n = trim((string)($item['n'] ?? $item['name'] ?? ''));
$i = (string)($item['i'] ?? $item['icon'] ?? '');
} else {
$n = trim((string)$item);
$i = '';
}
if ($n === '' || mb_strlen($n, 'UTF-8') > 20 || isset($seen[$n])) {
continue;
}
$seen[$n] = true;
$out[] = ['name' => $n, 'icon' => $i !== '' ? AnnivIcon::normalize($i) : self::defaultIcon($n)];
}
// ⚠️ 空数组是**合法**的(用户可能就是不想有分类),所以不在这里兜回预置;
// 只有"从来没配过"($raw === '')才给预置。
return $out;
}
public static function saveKinds(int $familyId, array $kinds): void
/** 只要名字(老的调用点、下拉、`in_array` 判断都还用它) */
public static function kinds(int $familyId): array
{
return array_column(self::kindsFull($familyId), 'name');
}
/** 名字 → 图标(设置页之外的一切渲染都问这里) */
public static function kindIcons(int $familyId): array
{
$out = [];
foreach (self::kindsFull($familyId) as $r) {
$out[$r['name']] = $r['icon'];
}
return $out;
}
/**
* 某个分类该用哪个图标。
*
* ⚠️ 三条回落,顺序不能反:
* ① 清单里配过的 → 用它(用户自己选的);
* ② 清单里没有(分类被删了、或者是老数据里的 kind)→ 按名字猜(PRESET_ICONS);
* ③ 还猜不出 → dot。
* 这样"删掉一个分类"之后,那些老纪念日**仍然有图标**,不会变成一片空白。
*/
public static function iconOf(int $familyId, string $kind): string
{
$kind = trim($kind);
if ($kind === '') {
return AnnivIcon::FALLBACK;
}
static $cache = [];
if (!isset($cache[$familyId])) {
$cache[$familyId] = self::kindIcons($familyId);
}
if (isset($cache[$familyId][$kind])) {
return $cache[$familyId][$kind];
}
return self::defaultIcon($kind);
}
/** 按名字猜一个图标(预置表 → 关键词 → dot) */
public static function defaultIcon(string $kind): string
{
$kind = trim($kind);
if (isset(self::PRESET_ICONS[$kind])) {
return self::PRESET_ICONS[$kind];
}
// 关键词兜底:用户自己起的名字里带"生日""结婚"……也该有个像样的图标
$guess = [
'生日' => 'cake', '寿' => 'cake',
'结婚' => 'ring', '婚' => 'ring',
'相识' => 'heart', '恋爱' => 'heart', '爱' => 'heart',
'忌' => 'candle', '追思' => 'candle', '逝' => 'candle',
'节' => 'star', '庆祝' => 'star',
'满月' => 'baby', '出生' => 'baby', '孩子' => 'baby',
'搬家' => 'home', '乔迁' => 'home', '家' => 'home',
'毕业' => 'book', '升学' => 'book', '入学' => 'book',
'旅' => 'plane', '行' => 'plane',
'合影' => 'camera', '照片' => 'camera',
'音乐' => 'music', '演出' => 'music',
'奖' => 'trophy', '晋升' => 'trophy', '成就' => 'trophy',
'入职' => 'clock', '周年' => 'clock',
'花' => 'flower', '祝福' => 'flower',
'礼物' => 'gift',
];
foreach ($guess as $needle => $icon) {
if (mb_strpos($kind, $needle, 0, 'UTF-8') !== false) {
return $icon;
}
}
return AnnivIcon::FALLBACK;
}
/**
* 保存分类清单。
*
* @param array $kinds 元素可以是 string(老调用点:只要名字)或
* array{name,icon}(新:带图标)
*/
public static function saveKinds(int $familyId, array $kinds): void
{
$out = [];
$seen = [];
foreach ($kinds as $k) {
$k = trim((string)$k);
if ($k === '' || mb_strlen($k, 'UTF-8') > 20) {
if (is_array($k)) {
$name = trim((string)($k['name'] ?? $k['n'] ?? ''));
$icon = (string)($k['icon'] ?? $k['i'] ?? '');
} else {
$name = trim((string)$k);
$icon = '';
}
if ($name === '' || mb_strlen($name, 'UTF-8') > 20 || isset($seen[$name])) {
continue;
}
if (!in_array($k, $out, true)) {
$out[] = $k;
}
$seen[$name] = true;
$out[] = [
'n' => $name,
'i' => $icon !== '' ? AnnivIcon::normalize($icon) : self::defaultIcon($name),
];
if (count($out) >= 30) { // 上限:一个下拉放不下更多了
break;
}
@@ -488,6 +640,9 @@ final class Anniversary
'date' => $date,
'title' => (string)($a['title'] ?? ''),
'kind' => (string)($a['kind'] ?? ''),
/* v62:分类图标(key)—— 日历拿它拼 emoji 前缀,卡片拿它画 SVG。
在这里一次性算好,别让每个渲染点各自去查一遍配置。 */
'icon' => self::iconOf($familyId, (string)($a['kind'] ?? '')),
'person_id' => (int)($a['person_id'] ?? 0),
'source' => (string)($a['source'] ?? 'custom'),
'note' => (string)($a['note'] ?? ''),