'每年(阳历)', 'lunar' => '每年(农历)', 'once' => '只此一次', ]; /** 庆典类纪念日大致的分类,给个默认值方便挑选(用户可自由填) */ public const PRESET_KINDS = ['生日', '结婚纪念日', '相识纪念日', '忌日', '节日', '其它']; 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'] ?? ''), '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'] ?? ''), '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' => [...]] 两块,各按日期排。 * ⚠️ 派生块用**这个人的出生年**来算"今年是几号"没法排序(农历要逐年换算), * 这里统一按 month/day 排(也就是"一年里按日历顺序"), * 与列表上显示的「X 月 X 日」完全一致 —— 不会出现"排出来的顺序和看到的不一样"。 * * @return array{custom:array,derived: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 = []; 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'] = []; } $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]; } // ---------------- 纪念日分类(v24 用户 2026-10-03 第 9 条)---------------- /** * 用户自定义的分类清单(family 级,存 settings)。 * * 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」 * * ⚠️ 与分类**落到每条纪念日上的 kind 字段**是两件事: * · 这里的清单 = 新建时下拉里给哪些选项(可增删); * · kind = 这一条实际选了哪个。 * 删掉一个分类**不**会把已用的纪念日改掉(那些 kind 仍然是那个字), * 只是新建时不再出现在下拉里 —— 强改历史数据比留着一个"没人认识的分类"更糟。 * * @return string[] 有序、去重、非空 */ public static function kinds(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; } } // ⚠️ 空数组是**合法**的(用户可能就是不想有分类),所以不在这里兜回预置; // 只有"从来没配过"($raw === '')才给预置。 return $out; } public static function saveKinds(int $familyId, array $kinds): void { $out = []; foreach ($kinds as $k) { $k = trim((string)$k); if ($k === '' || mb_strlen($k, 'UTF-8') > 20) { continue; } if (!in_array($k, $out, true)) { $out[] = $k; } 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 = []; foreach (self::all($familyId, $viewerId) 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'] ?? ''), 'person_id' => (int)($a['person_id'] ?? 0), '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; } }