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:
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
/**
|
||||
* 纪念日分类的图标(v62 用户 2026-10-18 第 2 条)。
|
||||
*
|
||||
* 用户原话:「设置按钮,点击后弹菜单,第一个菜单是分类设置,进去之后可以设置分类,
|
||||
* 每一类纪念日都有不同的图标……如果是用的 fontawesome,那么如果用户
|
||||
* 添加了自己的分类,就让用户自己选。在列表中、日历中,都要根据分类,
|
||||
* 显示不同的图标。」
|
||||
*
|
||||
* ⇒ 为什么**不自带 FontAwesome**(两条硬理由,别改回去):
|
||||
* ① 体积:整套 webfont(woff2 + css)≈ 100~300 KB,而这个项目是"点开就是一个
|
||||
* 家庭站点"的自建站,首页已经背了 antd(1.8 MB)。为了十几个图标再背一整套
|
||||
* 字体不划算,而且中文字形根本用不上。
|
||||
* ② 离线/内网:本项目所有静态资源都是本地 vendor 目录,不能挂 CDN(记忆里
|
||||
* "静态资源地址必须带版本号"那一条的同一个前提)——引 CDN 会在没外网的
|
||||
* 家庭内网里直接变成空白方块。
|
||||
* ⇒ 用**内置的行内 SVG 图标集**:一个图标一条 `<path>`(stroke 描边、currentColor
|
||||
* 取文字颜色),列表 / 卡片里直接内联;日历那种纯文本的地方用同一图标的
|
||||
* emoji 兜底(`emo()`)—— 日历的事件标题是纯文本,塞不进 SVG。
|
||||
*
|
||||
* ⚠️ **图标 key 是**契约**:它存在 settings(anniv_kinds)里,用户自己选。
|
||||
* 所以:① 已有 key 永远不要改名(老数据会变成"认不出的图标");
|
||||
* ② 新增图标只能往后加;③ 认不出的 key 一律回落 `dot`,不抛异常
|
||||
* (用户可能手改过库、或跨版本导入过配置)。
|
||||
*
|
||||
* @see \App\Anniversary::kindsFull() 分类清单(名称 + 图标)的读写
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class AnnivIcon
|
||||
{
|
||||
/** 认不出/没配时用哪个 */
|
||||
public const FALLBACK = 'dot';
|
||||
|
||||
/**
|
||||
* key => [中文名, emoji, SVG 内容(24×24 画布,stroke 描边)]
|
||||
*
|
||||
* ⚠️ SVG 内容里**不要**写 `fill` / `stroke` —— 由 svg() 统一给
|
||||
* `fill="none" stroke="currentColor"`,这样图标颜色永远跟着所在位置的文字色,
|
||||
* 浅底深底都不会变成"看不见的黑块"。
|
||||
*/
|
||||
public const LIST = [
|
||||
'cake' => ['蛋糕(生日)', '🎂',
|
||||
'<path d="M4 20h16"/>'
|
||||
. '<path d="M5 20v-4.6a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2V20"/>'
|
||||
. '<path d="M9 13.4V10M15 13.4V10"/>'
|
||||
. '<path d="M9 7.4c.9-.9.2-2-.2-2.4-.5.6-1.1 1.5-.2 2.4Z"/>'
|
||||
. '<path d="M15 7.4c.9-.9.2-2-.2-2.4-.5.6-1.1 1.5-.2 2.4Z"/>'],
|
||||
'ring' => ['戒指(结婚)', '💍',
|
||||
'<circle cx="12" cy="15" r="5.2"/>'
|
||||
. '<path d="M8.8 9.4 12 5.2l3.2 4.2"/>'
|
||||
. '<path d="M8.8 9.4h6.4"/>'],
|
||||
'heart' => ['爱心(相识/恋爱)', '❤️',
|
||||
'<path d="M12 20s-7-4.4-7-9.2A3.9 3.9 0 0 1 12 8.2a3.9 3.9 0 0 1 7 2.6C19 15.6 12 20 12 20Z"/>'],
|
||||
'candle' => ['蜡烛(忌日/追思)', '🕯️',
|
||||
'<path d="M9 21h6"/>'
|
||||
. '<rect x="9" y="10.5" width="6" height="10.5" rx="1"/>'
|
||||
. '<path d="M12 10.5V8.4"/>'
|
||||
. '<path d="M12 8.4c1-1 1-2.2 0-3-1 .8-1 2 0 3Z"/>'],
|
||||
'star' => ['星星(节日)', '⭐',
|
||||
'<path d="M12 3.6l2.6 5.3 5.8.8-4.2 4.1 1 5.8L12 16.9l-5.2 2.7 1-5.8-4.2-4.1 5.8-.8Z"/>'],
|
||||
'gift' => ['礼物', '🎁',
|
||||
'<rect x="4" y="11" width="16" height="9" rx="1"/>'
|
||||
. '<path d="M3 7.4h18v3.6H3zM12 7.4V20"/>'
|
||||
. '<path d="M12 7.4S10.6 3 8 3a2.2 2.2 0 0 0 0 4.4Z"/>'
|
||||
. '<path d="M12 7.4S13.4 3 16 3a2.2 2.2 0 0 1 0 4.4Z"/>'],
|
||||
'baby' => ['孩子(满月/出生)', '👶',
|
||||
'<circle cx="12" cy="7.6" r="3.4"/>'
|
||||
. '<path d="M6.4 20c0-3.6 2.5-6 5.6-6s5.6 2.4 5.6 6"/>'
|
||||
. '<path d="M10.5 7.2h.01M13.5 7.2h.01"/>'],
|
||||
'home' => ['家(搬家/乔迁)', '🏠',
|
||||
'<path d="M4 10.6 12 4l8 6.6V20H4z"/>'
|
||||
. '<path d="M9.6 20v-5.2h4.8V20"/>'],
|
||||
'book' => ['书(毕业/升学)', '📖',
|
||||
'<path d="M12 7.2c-1.6-1.4-3.6-2-6-2v11c2.4 0 4.4.6 6 2 1.6-1.4 3.6-2 6-2v-11c-2.4 0-4.4.6-6 2Z"/>'
|
||||
. '<path d="M12 7.2v11"/>'],
|
||||
'plane' => ['飞机(远行/旅行)', '✈️',
|
||||
'<path d="M21 4 3 11.2l6.4 2.2L11.6 20 21 4Z"/>'],
|
||||
'camera' => ['相机(合影/留念)', '📷',
|
||||
'<rect x="3.4" y="7.4" width="17.2" height="12" rx="2"/>'
|
||||
. '<circle cx="12" cy="13.4" r="3.4"/>'
|
||||
. '<path d="M8.6 7.4 10 4.8h4l1.4 2.6"/>'],
|
||||
'music' => ['音符(音乐/演出)', '🎵',
|
||||
'<circle cx="8" cy="17.4" r="2.6"/>'
|
||||
. '<circle cx="17" cy="15.4" r="2.6"/>'
|
||||
. '<path d="M10.6 17.4V7l9-2v10.4"/>'],
|
||||
'trophy' => ['奖杯(成就/晋升)', '🏆',
|
||||
'<path d="M8 4.6h8v4.8a4 4 0 0 1-8 0Z"/>'
|
||||
. '<path d="M8 6.2H5.4v1.4a3.2 3.2 0 0 0 3.2 3.2"/>'
|
||||
. '<path d="M16 6.2h2.6v1.4a3.2 3.2 0 0 1-3.2 3.2"/>'
|
||||
. '<path d="M12 13.4V17M8.6 20h6.8"/>'],
|
||||
'clock' => ['时钟(周年/入职)', '⏰',
|
||||
'<circle cx="12" cy="12" r="7.6"/>'
|
||||
. '<path d="M12 7.6V12l3 1.8"/>'],
|
||||
'flower' => ['花(表白/祝福)', '🌸',
|
||||
'<circle cx="12" cy="12" r="1.6"/>'
|
||||
. '<circle cx="12" cy="6.6" r="2.3"/>'
|
||||
. '<circle cx="16.9" cy="10.2" r="2.3"/>'
|
||||
. '<circle cx="15" cy="16" r="2.3"/>'
|
||||
. '<circle cx="9" cy="16" r="2.3"/>'
|
||||
. '<circle cx="7.1" cy="10.2" r="2.3"/>'],
|
||||
'leaf' => ['叶(自然而然)', '🌿',
|
||||
'<path d="M20 4C10 4 4.4 9 4.4 15.4c0 1.8.6 3.4 1.6 4.6C9 20 20 16 20 4Z"/>'
|
||||
. '<path d="M6 20C8.6 14 12 10.6 17 8"/>'],
|
||||
'bell' => ['铃铛(提醒/纪念)', '🔔',
|
||||
'<path d="M9 18a3 3 0 0 0 6 0"/>'
|
||||
. '<path d="M6.6 18h10.8c-1.4-1.4-1.8-2.6-1.8-4.6V11a4.6 4.6 0 0 0-9.2 0v2.4c0 2-.4 3.2-1.8 4.6Z"/>'
|
||||
. '<path d="M12 6.4V4.8"/>'],
|
||||
'dot' => ['圆点(其它)', '⚪',
|
||||
'<circle cx="12" cy="12" r="4"/>'],
|
||||
];
|
||||
|
||||
public static function has(string $key): bool
|
||||
{
|
||||
return $key !== '' && isset(self::LIST[$key]);
|
||||
}
|
||||
|
||||
/** 认不出的 key 一律回落,绝不抛异常(配置可能来自旧版本/手改) */
|
||||
public static function normalize(string $key): string
|
||||
{
|
||||
$key = strtolower(trim($key));
|
||||
return self::has($key) ? $key : self::FALLBACK;
|
||||
}
|
||||
|
||||
/** 中文名(给设置页的下拉用) */
|
||||
public static function label(string $key): string
|
||||
{
|
||||
return self::LIST[self::normalize($key)][0];
|
||||
}
|
||||
|
||||
public static function emo(string $key): string
|
||||
{
|
||||
return self::LIST[self::normalize($key)][1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 行内 SVG。
|
||||
*
|
||||
* ⚠️ 返回值是**可信 HTML**(不含任何用户输入:key 先 normalize,size 转 int),
|
||||
* 模板里直接 `<?= ... ?>` 输出,不要 e() 一遍(那会把标签转义成文字)。
|
||||
* aria-hidden:图标永远是"文字旁边的装饰",读屏器念分类名就够了。
|
||||
*/
|
||||
public static function svg(string $key, int $size = 15, string $cls = ''): string
|
||||
{
|
||||
$k = self::normalize($key);
|
||||
$n = max(8, min(96, $size));
|
||||
return '<svg class="ai ai-' . e($k) . ($cls !== '' ? ' ' . e($cls) : '')
|
||||
. '" viewBox="0 0 24 24" width="' . $n . '" height="' . $n . '"'
|
||||
. ' aria-hidden="true" focusable="false" fill="none" stroke="currentColor"'
|
||||
. ' stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">'
|
||||
. self::LIST[$k][2] . '</svg>';
|
||||
}
|
||||
|
||||
/** 设置页下拉里能选的全部图标:key => "emoji 中文名" */
|
||||
public static function choices(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (self::LIST as $k => $v) {
|
||||
$out[$k] = $v[1] . ' ' . $v[0];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
+175
-20
@@ -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'] ?? ''),
|
||||
|
||||
+8
-1
@@ -1487,11 +1487,18 @@ final class Calendar
|
||||
// 数据是**实时派生**的(生日读 persons、农历逐年换算),所以这里不查表、直接算。
|
||||
// ⚠️ 农历那一路必须按"这一年"换算 —— 同一条农历生日在不同年份落在不同公历日期。
|
||||
foreach (Anniversary::between($familyId, $personId, $from, $to) as $a) {
|
||||
/* 🔴 v62(用户 2026-10-18 第 2 条):「在列表中、**日历中**,都要根据分类,
|
||||
显示不同的图标。」
|
||||
⇒ 日历里的条目是**纯文本**(月格 / 周视图 / 日视图共用一个 title 字符串),
|
||||
塞不进行内 SVG,所以这里用同一套图标的 emoji 形态(`AnnivIcon::emo`)。
|
||||
图标 key 由 `Anniversary::occurrences()` 按分类算好带出来 ——
|
||||
不要在日历里再判一次"这是生日还是忌日"(两处判据迟早分叉)。 */
|
||||
$annIcon = AnnivIcon::emo((string)($a['icon'] ?? ''));
|
||||
$out[] = [
|
||||
'cal' => 0,
|
||||
'color' => '#B4633C',
|
||||
'calname' => '纪念日',
|
||||
'title' => '🎂 ' . (string)$a['title'],
|
||||
'title' => $annIcon . ' ' . (string)$a['title'],
|
||||
'date' => (string)$a['date'],
|
||||
'time' => '',
|
||||
'end_time'=> '',
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
<?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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user