diff --git a/app/AnnivIcon.php b/app/AnnivIcon.php new file mode 100644 index 0000000..cb49c78 --- /dev/null +++ b/app/AnnivIcon.php @@ -0,0 +1,165 @@ +`(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' => ['蛋糕(生日)', '🎂', + '' + . '' + . '' + . '' + . ''], + 'ring' => ['戒指(结婚)', '💍', + '' + . '' + . ''], + 'heart' => ['爱心(相识/恋爱)', '❤️', + ''], + 'candle' => ['蜡烛(忌日/追思)', '🕯️', + '' + . '' + . '' + . ''], + 'star' => ['星星(节日)', '⭐', + ''], + 'gift' => ['礼物', '🎁', + '' + . '' + . '' + . ''], + 'baby' => ['孩子(满月/出生)', '👶', + '' + . '' + . ''], + 'home' => ['家(搬家/乔迁)', '🏠', + '' + . ''], + 'book' => ['书(毕业/升学)', '📖', + '' + . ''], + 'plane' => ['飞机(远行/旅行)', '✈️', + ''], + 'camera' => ['相机(合影/留念)', '📷', + '' + . '' + . ''], + 'music' => ['音符(音乐/演出)', '🎵', + '' + . '' + . ''], + 'trophy' => ['奖杯(成就/晋升)', '🏆', + '' + . '' + . '' + . ''], + 'clock' => ['时钟(周年/入职)', '⏰', + '' + . ''], + 'flower' => ['花(表白/祝福)', '🌸', + '' + . '' + . '' + . '' + . '' + . ''], + 'leaf' => ['叶(自然而然)', '🌿', + '' + . ''], + 'bell' => ['铃铛(提醒/纪念)', '🔔', + '' + . '' + . ''], + 'dot' => ['圆点(其它)', '⚪', + ''], + ]; + + 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 ''; + } + + /** 设置页下拉里能选的全部图标:key => "emoji 中文名" */ + public static function choices(): array + { + $out = []; + foreach (self::LIST as $k => $v) { + $out[$k] = $v[1] . ' ' . $v[0]; + } + return $out; + } +} diff --git a/app/Anniversary.php b/app/Anniversary.php index 6058a58..fa29d48 100644 --- a/app/Anniversary.php +++ b/app/Anniversary.php @@ -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 有序、去重、非空 */ - 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'] ?? ''), diff --git a/app/Calendar.php b/app/Calendar.php index 2f81417..77767cb 100644 --- a/app/Calendar.php +++ b/app/Calendar.php @@ -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'=> '', diff --git a/app/HomeCards.php b/app/HomeCards.php new file mode 100644 index 0000000..a09dc22 --- /dev/null +++ b/app/HomeCards.php @@ -0,0 +1,323 @@ + [名字, 说明, 默认宽, 默认高, 详情页路由, 按钮文字, 是否有"显示几条"] + * + * ⚠️ key 就是模板名(`_card_.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 + */ + 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]; + } +} diff --git a/assets/css/app.css b/assets/css/app.css index aca0a30..b7a0b4f 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -6122,3 +6122,207 @@ h5.md-h, h6.md-h { font-size: 14px; } .view-choice-item input { margin-top: 3px; flex: 0 0 auto; } .view-choice-item .vci-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .view-choice-item .vci-body b { font-size: 14px; } + +/* ==================================================================== */ +/* v62(用户 2026-10-18 第 2 条):分类图标 */ +/* */ +/* 分类徽标 = 图标 + 名字。图标是行内 SVG(AnnivIcon),颜色取 currentColor */ +/* ⇒ 放在浅底徽标里就是灰字色,放在深色格子里自动变白,不需要第二套配色。 */ +/* ==================================================================== */ +.kind-badge { + display: inline-flex; align-items: center; gap: 4px; + vertical-align: middle; + max-width: 100%; +} +.kind-badge .ai { flex: 0 0 auto; opacity: .9; } +.kind-badge .kind-name { + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + max-width: 9em; /* 分类名最长 20 字,卡一行不撑破表格 */ +} +.ann-ic { display: inline-flex; align-items: center; flex: 0 0 auto; color: var(--muted); } +.ann-ic .ai { display: block; } + +/* ---- 分类设置页(days/settings)---- */ +.kind-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.kind-row { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--radius-s); + background: var(--surface); + /* ⚠️ 行内每项都可能收缩(分类名输入框),flex 子项默认 min-width:auto 会顶破 */ + min-width: 0; +} +.kind-row.is-ro { gap: 10px; } +.kind-move { display: inline-flex; gap: 2px; flex: 0 0 auto; } +.kind-prev { + flex: 0 0 auto; width: 30px; height: 30px; border-radius: 8px; + display: inline-flex; align-items: center; justify-content: center; + background: var(--line-2); color: var(--ink); +} +.kind-sel { flex: 0 0 auto; width: 190px; max-width: 46vw; } +.kind-name-in { flex: 1 1 140px; min-width: 0; } +.kind-name-ro { font-size: 14px; } +.kind-use { flex: 0 0 auto; font-size: 12px; color: var(--muted); min-width: 46px; text-align: right; } +/* 改过但没保存:保存按钮描一圈告警色 + 旁边一句小字(JS 加 .is-dirty / 去掉 hidden) + ⚠️ 用 `.btn-warn` 同一个 #E8A33D(全站"待处理"的语义色),不要另发明一个黄。 */ +.btn.is-dirty { box-shadow: 0 0 0 2px #E8A33D; } +.kind-dirty { color: #b57a19; margin-left: 10px; } +.kind-add { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + margin-top: 14px; padding: 10px; border-radius: var(--radius-s); + background: var(--line-2); +} +.kind-add .select { flex: 0 0 auto; width: 190px; max-width: 46vw; } +.kind-add .input { flex: 1 1 160px; min-width: 0; } +.kind-add-lb { font-size: 13px; color: var(--muted); } +.kind-icon-bank { display: none; } + +/* ==================================================================== */ +/* v62(用户 2026-10-18 第 3 条):首页卡片网格 */ +/* */ +/* 口径(与 app/HomeCards.php、templates/home_cards.php 的措辞一致): */ +/* · 一排 8 格;宽 2/4/6/8 → 一排 4 张 / 2 张 / 一大一小 / 独占一排 */ +/* · 高 2/4/6/8 行;一行 76px、间距 10px */ +/* */ +/* ⚠️ 为什么用 `grid-template-columns: repeat(8, minmax(0,1fr))` 而不是 */ +/* flex + 百分比:8 格制下"宽 6"(一大一小)只有网格能自然表达; */ +/* `minmax(0,1fr)` 里的 0 对应 flex 那条 `min-width:0` —— */ +/* 少了它,卡片里的长标题会把整列顶宽(v54 那个坑的同源)。 */ +/* ⚠️ 高度用 `grid-row: span N` 而不是给每张卡片算像素: */ +/* 这样"高 4"的两张卡片并排时天然等高,不依赖内容多少。 */ +/* ==================================================================== */ +.hc-grid { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + grid-auto-rows: 76px; + gap: 10px; + align-items: stretch; +} +.hc-card { + display: flex; flex-direction: column; + min-width: 0; min-height: 0; + margin: 0; + padding: 12px 14px; + overflow: hidden; +} +/* ⚠️ 必须单独写这一条,而且选择器要**比 `.card + .card` 更重**: + 上面 `.hc-card { margin: 0 }` 只有 0,1,0,而全局相邻间距 + `.card + .card { margin-top: 18px }` 是 0,2,0 —— 权重更高,会把 + 每一张**非首张**的卡片顶下去 18px(实测症状:高 2 期望 162 对得上, + 高 4/高 8 恒少 18px,因为网格首个子元素不命中 `+ .card`)。 + 间距本来就由 grid 的 `gap` 统一给,这里把相邻 margin 彻底清掉。 */ +.hc-grid > .card + .card { margin-top: 0; } +.hc-w2 { grid-column: span 2; } +.hc-w4 { grid-column: span 4; } +.hc-w6 { grid-column: span 6; } +.hc-w8 { grid-column: span 8; } +.hc-h2 { grid-row: span 2; } +.hc-h4 { grid-row: span 4; } +.hc-h6 { grid-row: span 6; } +.hc-h8 { grid-row: span 8; } + +.hc-head { + display: flex; align-items: center; gap: 10px; + flex: 0 0 auto; min-width: 0; margin-bottom: 8px; +} +.hc-head .cinema-h2 { margin: 0; flex: 1 1 auto; min-width: 0; } +.hc-head .hd-more { flex: 0 0 auto; } +/* 卡片正文:**自己滚**,不撑卡片(用户给的高度就是最终高度)。 */ +.hc-body { + flex: 1 1 auto; min-height: 0; min-width: 0; + overflow-y: auto; overscroll-behavior: contain; + scrollbar-width: none; -ms-overflow-style: none; +} +.hc-body::-webkit-scrollbar { width: 0; height: 0; } +/* 概述卡:左右两栏(问候/数字 | 天气),与原来的信息面板一致 */ +.hc-body.home-brief { align-content: flex-start; } +/* 日程卡:内部还要再分"全天区 + 时间网格",所以它自己是一个纵向 flex, + 滚动交给内层 `.hd-pane-body`(这样全天区才能钉在上面不动)。 */ +.hc-body.hc-agenda { display: flex; flex-direction: column; overflow: hidden; } +/* 卡片里的 pane 不再有 440px 的固定高(那是 v24 双栏时代的值) */ +.hc-card .hd-pane-body { flex: 1 1 auto; min-height: 0; } +.hc-card .ag-allday { max-height: 40%; } + +/* ---- 纪念日卡片 ---- */ +.hc-ann .ann-up-item { gap: 6px; padding: 7px 2px; } +.hc-ann .ann-days { min-width: 52px; } +.hc-ann .ann-title { font-size: 13.5px; } + +/* ---- 观影卡片:海报 + 片名 ---- */ +.hc-mv-list, .hc-bk-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; } +.hc-mv, .hc-bk { + display: flex; align-items: center; gap: 10px; min-width: 0; + padding: 6px 2px; border-bottom: 1px solid var(--line-2); +} +.hc-mv:last-child, .hc-bk:last-child { border-bottom: 0; } +.hc-mv-poster, .hc-bk-cover { + flex: 0 0 auto; width: 34px; height: 46px; border-radius: 4px; overflow: hidden; + background: var(--line-2); display: inline-flex; align-items: center; justify-content: center; +} +.hc-bk-cover { width: 32px; height: 44px; } +.hc-mv-poster img, .hc-bk-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.hc-mv-ph, .hc-bk-ph { font-size: 16px; opacity: .7; } +.hc-mv-main, .hc-bk-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.hc-mv-t, .hc-bk-t { + font-size: 13.5px; color: var(--ink); font-weight: 600; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.hc-bk-bar { display: block; height: 3px; border-radius: 2px; background: var(--line-2); overflow: hidden; } +.hc-bk-bar i { display: block; height: 100%; background: var(--brand); } +.hc-bk-st { + flex: 0 0 auto; font-size: 11px; padding: 1px 7px; border-radius: 999px; + background: var(--line-2); color: var(--muted); +} +.hc-bk-st.is-reading { background: #E7F0DA; color: #4A7226; } +.hc-bk-st.is-done { background: #E3EAF5; color: #3A5B8C; } + +/* ---- 联系人卡片:头像 + 名字,按卡片宽度自动折行 ---- */ +.hc-people { display: flex; flex-wrap: wrap; gap: 10px 12px; } +.hc-person { + display: flex; flex-direction: column; align-items: center; gap: 4px; + width: 56px; min-width: 0; text-decoration: none; +} +.hc-person-av { + width: 40px; height: 40px; border-radius: 50%; object-fit: cover; + display: inline-flex; align-items: center; justify-content: center; + font-size: 15px; font-weight: 600; +} +.hc-person-nm { + font-size: 11.5px; color: var(--muted); max-width: 100%; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.hc-person:hover .hc-person-nm { color: var(--ink); } + +/* ---- 窄屏:8 格 → 4 格 → 1 列 ---- */ +@media (max-width: 1180px) { + .hc-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .hc-w2 { grid-column: span 2; } + .hc-w4, .hc-w6, .hc-w8 { grid-column: span 4; } + .hc-h8 { grid-row: span 7; } +} +@media (max-width: 680px) { + .hc-grid { grid-template-columns: minmax(0, 1fr); grid-auto-rows: 64px; } + .hc-w2, .hc-w4, .hc-w6, .hc-w8 { grid-column: span 1; } + .hc-h6, .hc-h8 { grid-row: span 6; } +} + +/* ---- 首页卡片布局设置页 ---- */ +.hc-set-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.hc-set-row { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius-s); + background: var(--surface); min-width: 0; +} +.hc-set-row.is-dragged { opacity: .5; } +.hc-set-row.is-drop-before { border-top: 2px solid var(--brand); } +.hc-set-order { display: inline-flex; align-items: center; gap: 2px; flex: 0 0 auto; } +.hc-set-on { + display: flex; align-items: center; gap: 8px; + flex: 1 1 220px; min-width: 0; cursor: pointer; +} +.hc-set-name { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.hc-set-name b { font-size: 14px; } +.hc-set-name .small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.hc-set-dim { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; } +.hc-set-dim .select { width: 66px; } +.hc-set-n { width: 64px; } +.hc-set-null { width: 0; } diff --git a/index.php b/index.php index 5b8a6d2..8be00d8 100644 --- a/index.php +++ b/index.php @@ -29,6 +29,7 @@ use App\Expense; use App\Family; use App\Geo; use App\Home; +use App\HomeCards; use App\HomeMods; use App\Holiday; use App\Anniversary; @@ -847,6 +848,16 @@ switch ($r) { $homeMods = HomeMods::forPerson($me); $moduleCards = Home::moduleCards($familyId, $me, $soon, $tasks); + /* 🔴 v62(用户 2026-10-18 第 3 条):「今天,我们正式引入卡片的概念。」 + ⇒ 首页从"三块写死的区块"改成**卡片网格**: + · `$homeCards` 是这个人要摆哪几张、各自多宽多高(user_settings.home_cards); + · `$cardData` 只给那几张需要额外查询的卡片取数(纪念日/观影/读书/联系人)。 + ⚠️ 缺的卡片不查库 —— 关掉的卡片不该白跑一次 SQL。 + ⚠️ 日程/任务/往年的今天/概述这四张用的还是下面那几个变量(同一份数据), + 不要在模板里再查第二遍。 */ + $homeCards = HomeCards::layout($me); + $cardData = HomeCards::data($homeCards, $familyId, $me); + // 统计信息块:随机问候 + 今日天气 + 近七天的数字 $weekEnd = date('Y-m-d', strtotime('+7 days')); $st7 = Database::get()->prepare( @@ -936,6 +947,9 @@ switch ($r) { 'today' => $today, 'homeModules'=> $homeMods, 'moduleCards'=> $moduleCards, + // v62 卡片体系 + 'homeCards' => $homeCards, + 'cardData' => $cardData, // v30 第 5 条:通知块(放在「往年的今天」之前,见 templates/home.php) // ⚠️ 首页只给**最新的几条**(10 条)就够 —— 它是"瞥一眼"的地方, // 真正的消息中心在 ?r=notifications。 @@ -944,6 +958,84 @@ switch ($r) { ], 'home'); break; + /** + * 首页卡片布局设置(v62 用户 2026-10-18 第 3 条)。 + * + * 用户原话:「卡片的规格,我们可以定义一下,例如,把宽度定义为 8,高度可以是 + * 2、4、6、8……你可以先规划一些,实现出来,我们再讨论。」 + * + * ⇒ 这是**每人一份**的设置(首页怎么摆是很个人的事),存 user_settings。 + * ⚠️ 与「模块设置 → 导航」那套完全分开:导航管顶栏,这里只管首页的卡片。 + */ + case 'home/cards': + Auth::requireLogin(); + $me = (int)Auth::personId(); + View::render('home_cards', [ + 'title' => '首页卡片', + 'rows' => HomeCards::orderedForPicker($me), + ], 'home'); + break; + + /** + * 保存首页卡片布局。 + * + * 表单形状:每行一个 `order[]`(屏幕顺序)+ `on[]` 勾选 + `w[]` / `h[]` + * + `n[]`(只有"联系人"用得上)。 + * ⚠️ 顺序**按 order[] 取**,不按 POST 里 on 的顺序 —— 浏览器提交复选框的顺序 + * 并不保证与 DOM 顺序一致(有些浏览器按 name 分组提交)。 + */ + case 'home/cards_save': + Auth::requireLogin(); + csrf_check(); + $me = (int)Auth::personId(); + $order = array_map('strval', (array)($_POST['order'] ?? [])); + /* ⚠️ `on[]` 是**按卡片 key 作数组下标**提交的(`on[brief]=1`), + 所以判"勾没勾"必须 `isset($on[$k])` —— + 写成 `in_array($k, $on)` 是错的:那比的是**值**(全是 "1"), + 结果一个都匹配不上,保存出来是**空布局**(首页变成"卡片都关掉了")。 */ + $on = (array)($_POST['on'] ?? []); + $ws = (array)($_POST['w'] ?? []); + $hs = (array)($_POST['h'] ?? []); + $ns = (array)($_POST['n'] ?? []); + /* ▲▼ 是**没有 JS 时的兜底**(页面里还有拖动排序,两条路都要能走): + 先把屏幕顺序里这两个换位,再照新顺序建卡片列表 —— 于是"点了上移" + 与"改完再点保存"得到的是同一个结果。 */ + $move = (string)($_POST['move'] ?? ''); + if ($move !== '' && strpos($move, ':') !== false) { + [$dir, $mkey] = explode(':', $move, 2); + $mi = array_search($mkey, $order, true); + if ($mi !== false) { + $mj = $dir === 'up' ? $mi - 1 : $mi + 1; + if ($mj >= 0 && $mj < count($order)) { + [$order[$mi], $order[$mj]] = [$order[$mj], $order[$mi]]; + } + } + } + $items = []; + foreach ($order as $k) { + if (!isset($on[$k])) { + continue; + } + $items[] = [ + 'k' => $k, + 'w' => (int)($ws[$k] ?? 0), + 'h' => (int)($hs[$k] ?? 0), + 'n' => (int)($ns[$k] ?? 0), + ]; + } + // 一个都没勾 = 空首页。这是用户的选择(不是错误),照存。 + HomeCards::save($me, $items); + /* ⚠️ ▲▼ 只是"编辑中的一个动作",**不能跳去首页**: + 跳走等于把编辑器关掉,用户想再挪一格就得重新进来(实测就是这样, + 连点两下只会生效一次)。只有真正"保存"才回首页。 */ + if ($move !== '') { + flash('ok', '顺序已调整。'); + redirect('home/cards'); + } + flash('ok', '首页卡片已保存。'); + redirect('home'); + break; + // ==================== 家谱(以我为中心) ==================== case 'kin': $familyId = (int)Auth::familyId(); @@ -1760,7 +1852,12 @@ switch ($r) { $navKeys = Module::DEFAULT_NAV; // 一个都不勾 = 导航条会空掉,退回默认 } Module::saveNav($me, $navKeys); - HomeMods::save($me, $homeKeys); + /* ⚠️ v62:模块设置里**已经没有「首页」那一列**(首页改由「首页卡片」摆, + 见 HomeCards)。所以只有表单真的带了 `home[]` 才写 —— 否则每次保存导航 + 都会把 HomeMods 清成空数组("关了还在"的反面:"没动也丢")。 */ + if (array_key_exists('home', $_POST)) { + HomeMods::save($me, $homeKeys); + } // 主题色:家庭一份,只有管理员能改(非管理员提交时**静默忽略**,不报错) if (Auth::isAdmin() && isset($_POST['c']) && is_array($_POST['c'])) { @@ -4638,12 +4735,26 @@ $want = array_keys($want); foreach ($people as $pp) { $pmap[(int)$pp['id']] = $pp; } + /* 「接下来的日子」必须和下面两张表**用同一套可见性**: + 否则别人记的、与你无关的那条会孤零零出现在最上面, + 而下面「与我相关」「来自名册」里都找不到它(v62 实测踩到)。 + ⚠️ 只筛这个页面;日历是全家共享视图,照旧全显示。 */ + $upcoming = Anniversary::upcoming($familyId, $me, 60); + if (!empty($sp['hiddenIds'])) { + $hid = array_flip(array_map('intval', $sp['hiddenIds'])); + $upcoming = array_values(array_filter($upcoming, static function ($u) use ($hid) { + return !isset($hid[(int)($u['id'] ?? 0)]); + })); + } View::render('days/index', [ 'title' => '纪念日', 'custom' => $sp['custom'], 'derived' => $sp['derived'], - 'upcoming' => Anniversary::upcoming($familyId, $me, 60), + // v62:别人记的、与我无关的条数(只在页面上给一句小字) + 'hidden' => $sp['hidden'], + 'upcoming' => $upcoming, 'kinds' => Anniversary::kinds($familyId), + 'kindsFull' => Anniversary::kindsFull($familyId), 'kindCounts' => Anniversary::kindCounts($familyId), 'people' => $people, 'peopleMap' => $pmap, @@ -4651,12 +4762,102 @@ $want = array_keys($want); break; /** - * 纪念日分类设置(v24 用户 2026-10-03 第 9 条)。 + * 纪念日分类设置页(v62 用户 2026-10-18 第 2 条)。 * - * 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」 + * 用户原话:「设置按钮,点击后弹菜单,第一个菜单是分类设置,进去之后可以设置分类, + * 每一类纪念日都有不同的图标……如果用户添加了自己的分类,就让用户自己选。」 * - * 分类清单是**家庭级**的(全家共用一套,不然「结婚纪念日」在两个人屏幕上 - * 是两套叫法,筛选/看的时候对不上)。所以放在 settings 而不是 user_settings。 + * ⇒ 与日程/观影/任务三个模块同一套形态:☰ 下拉 → 点条目 → 独立页面。 + * + * 表单是**一张**(改图标 / 改名 / 排序 / 删 / 加 全在里面),靠按钮的 name 区分动作: + * save / del / move / add。为什么不用"每行一个小表单":那样改了 A 行图标再点 + * B 行删除,A 行那次修改会被浏览器丢掉(只提交被点的那张表单)—— + * "改了一半"是这类设置页最常见、也最难 debug 的投诉。 + */ + case 'days/settings': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + if (strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') { + Auth::requireWrite(); + csrf_check(); + $names = (array)($_POST['k_name'] ?? []); + $icons = (array)($_POST['k_icon'] ?? []); + // 用**原始下标**当键:空名字被跳过时下标也不能重排, + // 否则「删第 3 行」会删到第 3 个非空行(两者不是一回事)。 + $items = []; + $tooLong = 0; + foreach ($names as $i => $nm) { + $nm = trim((string)$nm); + if ($nm === '') { + continue; + } + if (mb_strlen($nm, 'UTF-8') > 20) { + $tooLong++; + continue; + } + $items[(string)$i] = [ + 'name' => $nm, + 'icon' => (string)($icons[$i] ?? ''), + ]; + } + $notice = '分类已保存。'; + if (isset($_POST['del'])) { + unset($items[(string)(int)$_POST['del']]); + $notice = '分类已删除(已用它的纪念日不变,只是新建时不再出现)。'; + } elseif (isset($_POST['move'])) { + $mv = explode(':', (string)$_POST['move'], 2); + $idx = (string)(int)($mv[0] ?? -1); + $keys = array_keys($items); + $pos = array_search($idx, $keys, true); + if ($pos !== false) { + $to = ($mv[1] ?? 'up') === 'up' ? $pos - 1 : $pos + 1; + if ($to >= 0 && $to < count($keys)) { + $tmp = $keys[$pos]; + $keys[$pos] = $keys[$to]; + $keys[$to] = $tmp; + $reordered = []; + foreach ($keys as $k) { + $reordered[$k] = $items[$k]; + } + $items = $reordered; + } + } + $notice = '顺序已调整。'; + } elseif (isset($_POST['add'])) { + $nn = trim((string)($_POST['new_name'] ?? '')); + if ($nn === '') { + flash('err', '新分类的名字是空的。'); + redirect('days/settings'); + } + if (mb_strlen($nn, 'UTF-8') > 20) { + flash('err', '分类名最多 20 个字。'); + redirect('days/settings'); + } + $items[] = ['name' => $nn, 'icon' => (string)($_POST['new_icon'] ?? '')]; + $notice = '分类已添加。'; + } + Anniversary::saveKinds($familyId, array_values($items)); + if ($tooLong > 0) { + flash('err', '有 ' . $tooLong . ' 个分类名超过 20 个字,已跳过。'); + } + flash('ok', $notice); + redirect('days/settings'); + } + View::render('days/settings', [ + 'title' => '纪念日分类', + 'kindsFull' => Anniversary::kindsFull($familyId), + 'kindCounts' => Anniversary::kindCounts($familyId), + 'meId' => $me, + ], 'days'); + break; + + /** + * 纪念日分类设置(v24 用户 2026-10-03 第 9 条)—— **老入口,保留兼容**。 + * + * v62 起前端的入口已经改成 `days/settings` 那一页(带图标)。这个 POST 端点留着: + * ① 老页面/书签里还有提交到它的表单; + * ② 反向验证脚本要能单独打它(证明"删一个分类"这条链路没断)。 + * ⚠️ 它只处理**名字**(add / del / 整份 kinds)——新格式的图标由 saveKinds 兜底补。 */ case 'days/kinds': $familyId = (int)Auth::familyId(); @@ -4724,6 +4925,8 @@ $want = array_keys($want); $anArgs = [ 'editing' => $editing, 'kinds' => Anniversary::kinds($familyId), + // v62:带图标的清单(下拉里用 emoji 打头,见 form.php) + 'kindsFull' => Anniversary::kindsFull($familyId), 'people' => Person::roster($familyId, $me, 'member', ''), /* 🔴 v41(用户第 10 条):「新建纪念日,采用弹框形式。」 直达 `days/new` 时也渲染成**打开着的弹框** —— diff --git a/templates/days/_settings_menu.php b/templates/days/_settings_menu.php index cb26429..ad8f52a 100644 --- a/templates/days/_settings_menu.php +++ b/templates/days/_settings_menu.php @@ -1,63 +1,51 @@ '' + . '' + . '' + . '', + ]; + $d = $paths[$name] ?? ''; + if ($d === '') { + return ''; + } + return ''; +}; ?> $e0[$k] ?? $def; $repeat = (string)($e0['repeat_type'] ?? 'solar'); -// 用户 2026-10-03 第 9 条:分类下拉的选项来自**可配置**的分类清单 -$kinds = (array)($kinds ?? \App\Anniversary::PRESET_KINDS); +/* 用户 2026-10-03 第 9 条:分类下拉的选项来自**可配置**的分类清单。 + 🔴 v62:清单改成"名称 + 图标"(`kindsFull`)。下拉里的 `