'每年(阳历)', 'lunar' => '每年(农历)', 'once' => '只此一次', ]; /** 庆典类纪念日大致的分类,给个默认值方便挑选(用户可自由填) */ 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'; // ---------------- ① 用户自建的纪念日 ---------------- public static function list(int $familyId): array { $st = Database::get()->prepare( 'SELECT * FROM anniversaries WHERE family_id = ? ORDER BY month, day, id' ); $st->execute([$familyId]); return $st->fetchAll(); } public static function find(int $familyId, int $id): ?array { $st = Database::get()->prepare('SELECT * FROM anniversaries WHERE id = ? AND family_id = ?'); $st->execute([$id, $familyId]); $r = $st->fetch(); return $r ?: null; } /** * 新建 / 修改一条纪念日。 * * @param array $d title / repeat_type / month / day / kind / note / leap * + **person_ids[]**(v24 第 25 批:可以关联多个人) * @return int 记录 id */ public static function save(int $familyId, array $d, int $id = 0): int { $db = Database::get(); $title = trim((string)($d['title'] ?? '')); if ($title === '') { $title = '纪念日'; } $repeat = (string)($d['repeat_type'] ?? 'solar'); if (!isset(self::REPEAT[$repeat])) { $repeat = 'solar'; } $month = max(1, min(12, (int)($d['month'] ?? 1))); $day = max(1, min(31, (int)($d['day'] ?? 1))); /* 🔴 v53(用户 2026-10-16 第 1 条):「纪念日需要记录年份,这样才能知道 **是第几个纪念日**。」 ⇒ 年份是**可选**的:用户没填(或填了个不合理的)就存 NULL, 页面只按月日过、不显示"第 N 个" —— 比猜一个年份诚实。 ⚠️ 合理区间 1900..2100:防手滑输入 0 / 999999 把"第 N 个"算成天文数字。 */ $year = (int)($d['year'] ?? 0); $year = ($year >= 1900 && $year <= 2100) ? $year : null; $kind = mb_substr(trim((string)($d['kind'] ?? '')), 0, 40, 'UTF-8'); $note = trim((string)($d['note'] ?? '')); $leap = !empty($d['leap']) ? 1 : 0; // ---- 关联的人(v24 第 25 批 用户第 2 条:一人 → 多人)---- // // ⚠️ 兼容老表单:只传 `person_id`(单值)时也认,包成一个人的数组。 // 这样任何还没来得及改的调用点(老链接、外部脚本)都不会静默丢人。 $pids = []; if (array_key_exists('person_ids', $d)) { foreach ((array)$d['person_ids'] as $x) { $x = (int)$x; if ($x > 0) { $pids[] = $x; } } } elseif (array_key_exists('person_id', $d)) { $x = (int)$d['person_id']; if ($x > 0) { $pids[] = $x; } } $pids = array_values(array_unique($pids)); // 只认这个家庭里的人(表单可以改,不能凭它挂到别人家的人身上) if ($pids) { $in = implode(',', array_fill(0, count($pids), '?')); $st = $db->prepare("SELECT id FROM persons WHERE family_id = ? AND id IN ($in)"); $st->execute(array_merge([$familyId], $pids)); $ok = array_map('intval', array_column($st->fetchAll(), 'id')); $pids = array_values(array_intersect($pids, $ok)); } // ⚠️ 老列 person_id 继续写**第一个**关联人(不写 NULL): // 它是老代码/老库的兜底读点,写成 NULL 会让"没升级到的读点"少一个人。 $pid = $pids[0] ?? null; if ($id > 0) { $db->prepare( 'UPDATE anniversaries SET title=?, repeat_type=?, year=?, month=?, day=?, is_leap=?, kind=?, note=?, person_id=? WHERE id=? AND family_id=?' )->execute([$title, $repeat, $year, $month, $day, $leap, $kind !== '' ? $kind : null, $note !== '' ? $note : null, $pid, $id, $familyId]); self::savePeople($id, $pids); return $id; } $db->prepare( 'INSERT INTO anniversaries (family_id, title, repeat_type, year, month, day, is_leap, kind, note, person_id) VALUES (?,?,?,?,?,?,?,?,?,?)' )->execute([$familyId, $title, $repeat, $year, $month, $day, $leap, $kind !== '' ? $kind : null, $note !== '' ? $note : null, $pid]); $newId = (int)$db->lastInsertId(); self::savePeople($newId, $pids); return $newId; } /** * 整组替换某个纪念日关联的人(v24 第 25 批)。 * * 「整组替换」而不是"逐条增删":表单交上来的就是**一份完整名单**, * 逐条比对会产生"先删后加"的中间态;一次 DELETE + 一次 INSERT 是确定结果。 */ public static function savePeople(int $anniversaryId, array $personIds): int { $db = Database::get(); $db->prepare('DELETE FROM anniversary_people WHERE anniversary_id = ?')->execute([$anniversaryId]); if (!$personIds) { return 0; } $ins = $db->prepare( 'INSERT IGNORE INTO anniversary_people (anniversary_id, person_id, sort_order) VALUES (?,?,?)' ); $n = 0; $i = 0; foreach ($personIds as $pid) { $pid = (int)$pid; if ($pid <= 0) { continue; } $i += 10; $ins->execute([$anniversaryId, $pid, $i]); $n++; } return $n; } /** * 某个纪念日关联的人(**含兜底**)。 * * ⚠️ 兜底逻辑(v24 第 25 批升级过渡期必须有): * 新表里没有行时,回落到老列 `person_id` —— 这样"升级跑到一半" * 或"老数据没回填成功"时,页面上仍然看得到那个人,而不是静默变成"没关联"。 * * @return int[] person id(按 sort_order) */ public static function peopleOf(array $row): array { static $cache = []; $aid = (int)($row['id'] ?? 0); if ($aid <= 0) { return []; } if (array_key_exists($aid, $cache)) { return $cache[$aid]; } $st = Database::get()->prepare( 'SELECT person_id FROM anniversary_people WHERE anniversary_id = ? ORDER BY sort_order, person_id' ); $st->execute([$aid]); $out = array_map('intval', array_column($st->fetchAll(), 'person_id')); if (!$out) { $one = (int)($row['person_id'] ?? 0); if ($one > 0) { $out = [$one]; } } $cache[$aid] = $out; return $out; } /** * 一批纪念日的关联人:`anniversary_id => [person_id, ...]`(避免逐行查库)。 */ public static function peopleMap(array $ids): array { $ids = array_values(array_filter(array_map('intval', $ids))); if (!$ids) { return []; } $in = implode(',', array_fill(0, count($ids), '?')); $st = Database::get()->prepare( "SELECT anniversary_id, person_id FROM anniversary_people WHERE anniversary_id IN ($in) ORDER BY anniversary_id, sort_order, person_id" ); $st->execute($ids); $out = []; foreach ($st->fetchAll() as $r) { $out[(int)$r['anniversary_id']][] = (int)$r['person_id']; } return $out; } public static function remove(int $familyId, int $id): void { Attachment::removeAll(self::ATTACH, $id); // 关联表由外键 ON DELETE CASCADE 一起走;这里不必手动删 Database::get()->prepare('DELETE FROM anniversaries WHERE id = ? AND family_id = ?') ->execute([$id, $familyId]); } // ---------------- ② 派生:名册里每个人的生日 ---------------- /** * 把名册里所有人的生日,当成"纪念日"派生出来。 * * 用户原话:「生日本身就是纪念日的一种」——所以这里不去 persons 里写任何东西, * 只读出生日、包成与自建纪念日**同一个形状**的行,上层(列表页 / 日历)一视同仁。 * * @return array 每项含 title/kind/month/day/repeat_type/person_id/source/… */ public static function birthdays(int $familyId, int $viewerId): array { $people = Person::map($familyId); $out = []; foreach ($people as $pid => $p) { $pid = (int)$pid; // 阳历生日(列名叫 birthday,读取一律走 Birthday::solarOf) $solar = Birthday::dateOnly(Birthday::solarOf($p)); if ($solar !== '') { $out[] = [ 'id' => 0, 'title' => (string)($p['name'] ?? '') . ' 的生日', 'kind' => '生日', 'repeat_type' => 'solar', 'month' => (int)substr($solar, 5, 2), 'day' => (int)substr($solar, 8, 2), 'is_leap' => 0, 'person_id' => $pid, 'person_name' => (string)($p['name'] ?? ''), /* #87:头像给端上画"生日只读卡"用(key,端上拼 URL) */ 'avatar' => (string)($p['avatar'] ?? ''), 'avatar_color' => (string)($p['avatar_color'] ?? ''), 'source' => 'birthday_solar', 'born' => $solar, ]; } // 农历生日(**日期部分是农历**,所以月/日是农历的月日) $lunar = Birthday::dateOnly((string)($p['birthday_lunar'] ?? '')); if ($lunar !== '') { $out[] = [ 'id' => 0, 'title' => (string)($p['name'] ?? '') . ' 的农历生日', 'kind' => '生日', 'repeat_type' => 'lunar', 'month' => (int)substr($lunar, 5, 2), 'day' => (int)substr($lunar, 8, 2), 'is_leap' => (int)($p['birthday_lunar_leap'] ?? 0), 'person_id' => $pid, 'person_name' => (string)($p['name'] ?? ''), 'avatar' => (string)($p['avatar'] ?? ''), 'avatar_color' => (string)($p['avatar_color'] ?? ''), 'source' => 'birthday_lunar', 'born' => $lunar, ]; } } unset($viewerId); return $out; } /** 自建纪念日 + 派生生日,合成一份"全部纪念日" */ public static function all(int $familyId, int $viewerId): array { $out = self::birthdays($familyId, $viewerId); foreach (self::list($familyId) as $r) { $r['source'] = 'custom'; $r['person_name'] = ''; $out[] = $r; } return $out; } /** * 两块分开(v24 用户 2026-10-03 第 9 条)。 * * 用户原话:「纪念日中,手动添加的纪念日和来自其他模块的,例如生日的纪念日, * 要分开,手动纪念日在上面。」 * * 所以返回 ['custom' => [...], 'derived' => [...]], 'hidden' => N 三块,各按日期排。 * ⚠️ 派生块用**这个人的出生年**来算"今年是几号"没法排序(农历要逐年换算), * 这里统一按 month/day 排(也就是"一年里按日历顺序"), * 与列表上显示的「X 月 X 日」完全一致 —— 不会出现"排出来的顺序和看到的不一样"。 * * 🔴 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 { $rows = self::list($familyId); // 一次把所有人的关联人查出来(避免逐行查库)——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'] = ''; $aid = (int)$r['id']; if (!empty($pmap[$aid])) { $r['person_ids'] = $pmap[$aid]; } elseif ((int)($r['person_id'] ?? 0) > 0) { // 兜底:新表没有行就看老列(升级过渡期) $r['person_ids'] = [(int)$r['person_id']]; } 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); // 派生行(名册生日)本来就知道是谁,统一挂上 person_ids,模板无须分两条路 foreach ($derived as &$dv) { $dv['person_ids'] = (int)($dv['person_id'] ?? 0) > 0 ? [(int)$dv['person_id']] : []; } unset($dv); $byMD = static function (array $a, array $b): int { return ((int)($a['month'] ?? 0) <=> (int)($b['month'] ?? 0)) ?: ((int)($a['day'] ?? 0) <=> (int)($b['day'] ?? 0)) ?: strcmp((string)($a['title'] ?? ''), (string)($b['title'] ?? '')); }; usort($custom, $byMD); usort($derived, $byMD); return ['custom' => $custom, 'derived' => $derived, 'hidden' => $hidden, 'hiddenIds' => $hiddenIds]; } // ---------------- 纪念日分类(v24 用户 2026-10-03 第 9 条)---------------- /** * 用户自定义的分类清单(family 级,存 settings)—— **带图标**(v62)。 * * 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」 * v62 追加:「每一类纪念日都有不同的图标……如果用户添加了自己的分类,就让用户自己选。」 * * ⚠️ 与分类**落到每条纪念日上的 kind 字段**是两件事: * · 这里的清单 = 新建时下拉里给哪些选项(可增删、可换图标); * · kind = 这一条实际选了哪个。 * 删掉一个分类**不**会把已用的纪念日改掉(那些 kind 仍然是那个字), * 只是新建时不再出现在下拉里 —— 强改历史数据比留着一个"没人认识的分类"更糟。 * * ⚠️ 存储形状变过一次(**必须兼容老数据**): * v24 起:`["生日","结婚纪念日"]` —— 纯字符串数组 * v62 起:`[{"n":"生日","i":"cake"}, ...]` —— 名称 + 图标 * 读到字符串就按"没配图标"处理(用 PRESET_ICONS / 名字猜一个), * 读到对象就取 n/i。**不改老库、不做一次性迁移**:读的时候兼容,写的时候 * 自然升级 —— 迁移脚本在"用户还没打开过设置页"时没有任何收益。 * * @return 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)) { // 没配过 → 用预置那套(每个都配好图标) $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; } /** 只要名字(老的调用点、下拉、`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) { 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; } $seen[$name] = true; $out[] = [ 'n' => $name, 'i' => $icon !== '' ? AnnivIcon::normalize($icon) : self::defaultIcon($name), ]; if (count($out) >= 30) { // 上限:一个下拉放不下更多了 break; } } Setting::set($familyId, 'anniv_kinds', json_encode($out, JSON_UNESCAPED_UNICODE)); } /** 每个分类被用了几条(设置页要显示"这个分类有没有在用") */ public static function kindCounts(int $familyId): array { $st = Database::get()->prepare( 'SELECT kind, COUNT(*) AS n FROM anniversaries WHERE family_id = ? AND kind IS NOT NULL AND kind <> "" GROUP BY kind' ); $st->execute([$familyId]); $out = []; foreach ($st->fetchAll() as $r) { $out[(string)$r['kind']] = (int)$r['n']; } return $out; } // ---------------- ③ 这一年会落在哪几天(给日历用) ---------------- /** * 某一年里,这些纪念日各落在哪个公历日期。 * * ⚠️ 农历那一路必须**逐年换算**:农历新年在公历 1~2 月, * 所以同一条"农历冬月初一"在不同年份对应的公历日期完全不同, * 不存在"存一个公历日期年年用"的可能。 * * @return array */ public static function occurrences(int $familyId, int $viewerId, int $year): array { $out = []; $all = self::all($familyId, $viewerId); /* 🔴🔴 #91d(用户 2026-11-05):「你去查一下这个领证纪念日到底关联了几个人, 数据库里。」 查库结果:`anniversaries #2` 的**老列** `person_id = 1`(只能存一个), 但 `anniversary_people` 里有 **2 人**(杨永全 + 洪博为)。 端上编辑框只读老列 ⇒ 只带出 1 个人,另一个被静默丢掉。 ⇒ 在这里一次性把多对多关系查出来。 ⚠️ **一次查全表**而不是逐条查:`peopleMap()` 正是为此写的 (`split()` 早就这么用了;这里漏了)。 ⚠️ 不能直接用老列兜底就完事 —— 老列是升级前的遗留字段, 两边不一致时**以多对多表为准**(用户在新版界面上改的是那张表)。 ⚠️ 多对多表里没有行时才退回老列(升级过渡期还没回填的条目)。 */ $pmap = self::peopleMap(array_column($all, 'id')); foreach ($all as $a) { $repeat = (string)($a['repeat_type'] ?? 'solar'); $m = (int)($a['month'] ?? 0); $d = (int)($a['day'] ?? 0); if ($m < 1 || $m > 12 || $d < 1 || $d > 31) { continue; } $date = null; if ($repeat === 'lunar') { $leap = !empty($a['is_leap']) && Lunar::leapMonth($year) === $m; $sol = Lunar::lunarToSolar($year, $m, $d, $leap); if ($sol === null) { // 这一年这个月没有这一天(如腊月三十遇上 29 天的腊月)→ 退到当月最后一天 $max = $leap ? Lunar::leapDays($year) : Lunar::monthDays($year, $m); $sol = Lunar::lunarToSolar($year, $m, max(1, min($d, $max)), $leap); } if ($sol === null) { continue; } $date = sprintf('%04d-%02d-%02d', $sol['year'], $sol['month'], $sol['day']); } else { /* 🔴 v53:`once`(只此一次)**有年份时只在那一年出现**。 老行为是"落在公历分支 ⇒ 年年都冒出来",与"只此一次"自相矛盾; 以前没有年份,没法判"哪一年",只能那样漂着。 ⚠️ 没有年份的 `once` 仍然保持老行为(不知道是哪一年,不能凭空丢掉)。 */ $onceYear = (int)($a['year'] ?? 0); if ($repeat === 'once' && $onceYear > 0 && $onceYear !== $year) { continue; } // 公历(含"只此一次"):2/29 落在平年时退到 2/28 $dd = $d; if (!checkdate($m, $dd, $year)) { $dd = min($dd, (int)date('t', mktime(0, 0, 0, $m, 1, $year))); } $date = sprintf('%04d-%02d-%02d', $year, $m, $dd); } $age = null; if (($a['source'] ?? '') !== 'custom' && !empty($a['born'])) { $age = Birthday::ageOn((string)$a['born'], $date); } /* 🔴 v53(用户 2026-10-16 第 1 条):算出**这是第几个纪念日**。 规则:`nth = 本次年份 - 起始年份 + 1`(起始那年就是"第 1 个")。 ⚠️ 只用**用户自己填的** `year`(`birthdays()` 派生的生日条目没有这一项, 它们走 `age` 那套,别混)。没填年份 ⇒ null ⇒ 页面不显示序号。 ⚠️ `nth < 1` 不显示(年份填到了未来 ⇒ 这一年的这次还没到"第 1 个")。 */ $y0 = (int)($a['year'] ?? 0); // ⚠️ 派生的生日条目没有 year(它们走 age 那套),所以这里判 source $nth = (($a['source'] ?? 'custom') === 'custom') ? self::nthFor($y0 > 0 ? $y0 : null, $year) : null; $out[] = [ '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), /* #91d:**完整关联人列表**(多对多表为准,空则退回老列)。 端上编辑纪念日要带出全部关联人、只读卡要显示"关联了谁"。 */ 'person_ids' => (function () use ($pmap, $a): array { $aid = (int)($a['id'] ?? 0); if ($aid > 0 && !empty($pmap[$aid])) { return array_map('intval', $pmap[$aid]); } $one = (int)($a['person_id'] ?? 0); return $one > 0 ? [$one] : []; })(), /* 🔴 #87(用户 2026-11-05):「如果这个纪念日来自生日… 点击之后,显示这个人的头像、姓名,以及生日的『日』和日期就可以了。」 ⇒ 端上要画头像,所以这里必须带出来。 原来 `birthdays()` 算出来的 `person_name` 在这里**被丢掉了** —— `occurrences()` 逐个字段挑着抄,漏了它。 ⇒ 一并补 `person_name` + `avatar` + `avatar_color`。 `avatar` 是 key(如 `avatars/2026xxx.jpg`), 端上自己拼完整 URL(与任务负责人头像同一口径)。 ⚠️ `all()` 里自建纪念日那批的 `person_name` 是 `''` (自建纪念日可能关联多人、也可能不关联人), 这里原样透传即可,端上"名字为空就不画头像"。 */ 'person_name' => (string)($a['person_name'] ?? ''), 'avatar' => (string)($a['avatar'] ?? ''), 'avatar_color' => (string)($a['avatar_color'] ?? ''), /* 「日」—— 用户明确要"生日的『日』"。 直接给整数,端上做"大字显示"(如「17」)。 ⚠️ 用 `$a['day']` 而不是从 `$date` 里截: `$a['day']` 是**用户填的那一天**(农历生日时是农历日), `$date` 是换算出来的公历日期 —— 两者在农历生日上不同, 用户要看的是"生日那天"(农历初五就该显示 5)。 */ 'day' => (int)($a['day'] ?? 0), 'month' => (int)($a['month'] ?? 0), /* 🔴 #88(用户 2026-11-05):卡片右侧要显示两种日期写法 —— 「如果是农历的话,就要写上农历十月二日。下面还要写上阳历: 换算完之后,今年阳历是多少?比如11月10日」 ⇒ 服务端直接给**成品文字**,端上不自己拼: · `lunar_label` = 「十月二日」/「闰四月初五」(农历生日才有;其余空) · `solar_md` = 「11月10日」(今年实际发生那天的公历月日) ⚠️ 农历文字为什么不在端上拼:`Lunar` 有完整的月名/日名对照表 (正月/冬月/腊月、初一/廿三…),端上再抄一份必然漂移, 而且闰月(闰四月)这种特例得一起处理。 ⚠️ `solar_md` 用 `$date`(今年换算后的实际日期)而不是 `$a['month']/$a['day']`: 农历生日的 month/day 是**农历**月日,直接拼出来会是错的公历。 ⚠️ 农历生日时 `lunar_label` 用 `$a['month']/$a['day']`(才是农历月日); 公历生日时留空 —— 那时"农历"这一行没有意义。 */ 'lunar_label' => ($repeat === 'lunar' && $m >= 1 && $d >= 1) ? (\App\Lunar::monthName($m, !empty($a['is_leap'])) . \App\Lunar::dayName($d)) : '', 'solar_md' => (strlen((string)$date) >= 10) ? ((int)substr((string)$date, 5, 2)) . '月' . ((int)substr((string)$date, 8, 2)) . '日' : '', 'source' => (string)($a['source'] ?? 'custom'), 'note' => (string)($a['note'] ?? ''), 'id' => (int)($a['id'] ?? 0), 'age' => $age, 'repeat' => $repeat, 'year' => $y0 > 0 ? $y0 : null, // 用户填的起始年(没有则 null) 'nth' => $nth, // 第几个(没有年份则 null) ]; } return $out; } /** * 一段时间内的纪念日(日历画格子用)。 * @return array 按日期升序 */ public static function between(int $familyId, int $viewerId, string $from, string $to): array { $y1 = (int)substr($from, 0, 4); $y2 = (int)substr($to, 0, 4); $out = []; for ($y = $y1; $y <= $y2; $y++) { foreach (self::occurrences($familyId, $viewerId, $y) as $o) { if ($o['date'] >= $from && $o['date'] <= $to) { $out[] = $o; } } } usort($out, static fn($a, $b) => strcmp($a['date'], $b['date'])); return $out; } /** 接下来 n 天内的纪念日(列表页头部"临近"用) */ public static function upcoming(int $familyId, int $viewerId, int $withinDays = 60): array { $today = date('Y-m-d'); $to = date('Y-m-d', strtotime('+' . max(1, $withinDays) . ' days')); $rows = self::between($familyId, $viewerId, $today, $to); foreach ($rows as &$r) { $r['days'] = (int)((strtotime($r['date']) - strtotime($today)) / 86400); } unset($r); return $rows; } public static function repeatWord(string $k): string { return self::REPEAT[$k] ?? $k; } /** * 🔴 v53(用户 2026-10-16 第 1 条):**这是第几个纪念日**。 * * `nth = 年份 - 起始年 + 1`(起始那年就是"第 1 个");没有起始年(或算出来 < 1)返回 null。 * * ⚠️ **单点来源**:`occurrences()`(临近区)与 `days/index.php`(我自己添加的那张表) * 都调它 —— 两处各写一遍 `date('Y') - $year + 1` 是本项目反复踩过的 * "同一个概念两处计算"型坑(改一处忘一处 ⇒ 两个页面显示的序号能对不上)。 * ⚠️ 用**今天所在的年**("今年是第几个")而不是"下一次发生的那年"—— * 否则一个 12 月的纪念日在 1 月看会显示成"第 N+1 个",反直觉。 */ public static function nthFor(?int $startYear, ?int $inYear = null): ?int { $y0 = (int)($startYear ?? 0); if ($y0 <= 0) { return null; } $y = $inYear !== null ? (int)$inYear : (int)date('Y'); $n = $y - $y0 + 1; return $n >= 1 ? $n : null; } }