'每年(阳历)', '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 / person_id / leap * @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))); $kind = mb_substr(trim((string)($d['kind'] ?? '')), 0, 40, 'UTF-8'); $note = trim((string)($d['note'] ?? '')); $pid = (int)($d['person_id'] ?? 0) ?: null; $leap = !empty($d['leap']) ? 1 : 0; if ($id > 0) { $db->prepare( 'UPDATE anniversaries SET title=?, repeat_type=?, month=?, day=?, is_leap=?, kind=?, note=?, person_id=? WHERE id=? AND family_id=?' )->execute([$title, $repeat, $month, $day, $leap, $kind !== '' ? $kind : null, $note !== '' ? $note : null, $pid, $id, $familyId]); return $id; } $db->prepare( 'INSERT INTO anniversaries (family_id, title, repeat_type, month, day, is_leap, kind, note, person_id) VALUES (?,?,?,?,?,?,?,?,?)' )->execute([$familyId, $title, $repeat, $month, $day, $leap, $kind !== '' ? $kind : null, $note !== '' ? $note : null, $pid]); return (int)$db->lastInsertId(); } public static function remove(int $familyId, int $id): void { Attachment::removeAll(self::ATTACH, $id); 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 { $custom = []; foreach (self::list($familyId) as $r) { $r['source'] = 'custom'; $r['person_name'] = ''; $custom[] = $r; } $derived = self::birthdays($familyId, $viewerId); $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 { // 公历(含"只此一次"):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); } $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, ]; } 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; } }