['观影', '#7A4E8C'], 'trip' => ['旅行', '#3F7D5A'], ]; /** * 建本时的预设色板(与影院管理同一个交互:取色器 + 点一下就换)。 * * ⚠️ 一律挑**中低饱和**的:月网格里日程条是**白字压在色块上**, * 太浅看不清字,太艳会盖过整个月视图。 */ public const PRESET_CAL_COLORS = [ '#A63030', '#A65C30', '#A68930', '#97A630', '#6BA630', '#3FA630', '#30A64E', '#30A67A', '#30A6A6', '#307AA6', '#304EA6', '#3F30A6', '#6B30A6', '#9730A6', '#A63089', '#A6305C', ]; /** * 提醒档位:提前多久(分钟)—— 对应 RFC 5545 的 VALARM / TRIGGER。 * * ⚠️ 顺序就是用户 2026-10-02 第 6 条原话的顺序,别按数值大小重排: * 下拉里"5 分钟、10 分钟、15 分钟…1 天"读起来是递进的, * 按数值排也一样,但一改动顺序用户就会以为自己选错了。 */ public const REMINDERS = [ 5 => '提前 5 分钟', 10 => '提前 10 分钟', 15 => '提前 15 分钟', 30 => '提前 30 分钟', 60 => '提前 1 小时', 120 => '提前 2 小时', 240 => '提前 4 小时', 480 => '提前 8 小时', 720 => '提前 12 小时', 1440 => '提前 1 天', ]; /** 新建一本 */ public static function create(int $familyId, int $ownerId, string $name, string $color, string $kind = 'custom'): int { $db = Database::get(); $db->prepare( 'INSERT INTO calendars (family_id, owner_id, name, color, kind, position) VALUES (?,?,?,?,?,?)' )->execute([ $familyId, $ownerId, mb_substr(trim($name), 0, 50, 'UTF-8'), // ⚠️ 统一**大写** hex:normalize() 会小写化,而我读回来的颜色要跟 // 常量里的 '#7A4E8C' 之类直接比字符串(日历本颜色是给页面用的, // 大小写混着存会让"颜色一样吗"这种判断时对时错)。 strtoupper(AvatarColor::normalize($color) ?: '#4A7C82'), $kind === 'movie' || $kind === 'trip' ? $kind : 'custom', self::nextPosition($ownerId), ]); return (int)$db->lastInsertId(); } public static function update(int $id, string $name, string $color): void { Database::get()->prepare('UPDATE calendars SET name = ?, color = ? WHERE id = ?') ->execute([ mb_substr(trim($name), 0, 50, 'UTF-8'), strtoupper(AvatarColor::normalize($color) ?: '#4A7C82'), $id, ]); } /** 删一本(默认本不许删 —— 删了观影/旅行就再也回不来了) */ public static function delete(int $id): void { Database::get()->prepare("DELETE FROM calendars WHERE id = ? AND kind = 'custom'")->execute([$id]); } public static function find(int $id): ?array { $st = Database::get()->prepare('SELECT * FROM calendars WHERE id = ?'); $st->execute([$id]); $r = $st->fetch(); return $r ?: null; } /** * 我能看到的日历本 = 我自己的 + 别人共享给我的(带上我对它的权限)。 * * `perm` 的含义:'owner'(我的,想怎么改都行)/ 'write' / 'read'。 */ public static function mine(int $familyId, int $personId): array { // ⚠️ 共享给我的本子也要带上**主人是谁**(页面要显示"谁共享给我的"), // 而且按"我看他叫什么"显示(owner_nick)—— 用户第 3 条。 $st = Database::get()->prepare( "SELECT c.*, 'owner' AS perm, 0 AS shared, p.name AS owner_name, p.avatar AS owner_avatar, p.avatar_color AS owner_color, n.nickname AS owner_nick FROM calendars c JOIN persons p ON p.id = c.owner_id LEFT JOIN nicknames n ON n.target_id = c.owner_id AND n.viewer_id = ? WHERE c.family_id = ? AND c.owner_id = ? UNION ALL SELECT c.*, s.perm AS perm, 1 AS shared, p.name AS owner_name, p.avatar AS owner_avatar, p.avatar_color AS owner_color, n.nickname AS owner_nick FROM calendar_shares s JOIN calendars c ON c.id = s.calendar_id JOIN persons p ON p.id = c.owner_id LEFT JOIN nicknames n ON n.target_id = c.owner_id AND n.viewer_id = ? WHERE s.person_id = ? AND c.family_id = ? ORDER BY kind = 'custom', position, id" ); $st->execute([$personId, $familyId, $personId, $personId, $personId, $familyId]); return $st->fetchAll(); } /** 这本日历我能写吗(owner 或共享为 write) */ public static function canWrite(array $cal, int $personId): bool { if ((int)$cal['owner_id'] === $personId) { return true; } return ($cal['perm'] ?? '') === 'write'; } /** * 按 id 取一本「我能看到的」日历(带 perm / shared)。 * * ⚠️ 判权限时**必须**走这里而不是 Calendar::find():find() 返回的是 calendars * 表原行,里面没有 perm —— 于是「共享给我的、可读写的本子」一律被判成不能写, * 被共享的人建了日程也改不了(蹲在 calendar/event 页上看只读提示)。 */ public static function mineOne(int $familyId, int $personId, int $calendarId): ?array { foreach (self::mine($familyId, $personId) as $c) { if ((int)$c['id'] === $calendarId) { return $c; } } return null; } /** 给某人补建两本默认日历(幂等) */ public static function seedDefaults(int $familyId, int $personId): void { foreach (self::DEFAULT_KINDS as $kind => list($name, $color)) { $st = Database::get()->prepare( 'SELECT id FROM calendars WHERE owner_id = ? AND kind = ? AND name = ?' ); $st->execute([$personId, $kind, $name]); if ($st->fetchColumn() === false) { self::create($familyId, $personId, $name, $color, $kind); } } } /** 升级 / 安装时给全家每个账号补一遍(老库也要有这两本) */ public static function seedDefaultsForFamily(int $familyId): void { $st = Database::get()->prepare( "SELECT p.id FROM persons p JOIN accounts a ON a.person_id = p.id WHERE p.family_id = ? AND a.status = 'active'" ); $st->execute([$familyId]); foreach ($st->fetchAll() as $r) { self::seedDefaults($familyId, (int)$r['id']); } } // ---------------- 共享 ---------------- public static function shares(int $calendarId): array { $st = Database::get()->prepare( 'SELECT s.person_id, s.perm, p.name, p.avatar, p.avatar_color FROM calendar_shares s JOIN persons p ON p.id = s.person_id WHERE s.calendar_id = ? ORDER BY p.id' ); $st->execute([$calendarId]); return $st->fetchAll(); } public static function setShare(int $calendarId, int $personId, string $perm): void { Database::get()->prepare( 'INSERT INTO calendar_shares (calendar_id, person_id, perm) VALUES (?,?,?) ON DUPLICATE KEY UPDATE perm = VALUES(perm)' )->execute([$calendarId, $personId, $perm === 'write' ? 'write' : 'read']); } public static function dropShare(int $calendarId, int $personId): void { Database::get()->prepare('DELETE FROM calendar_shares WHERE calendar_id = ? AND person_id = ?') ->execute([$calendarId, $personId]); } // ---------------- 日程 ---------------- /** * 新建一条日程。 * * ⚠️ v24(第 5、6 条):起止是**完整的 DATETIME(精确到秒)**,不再分成 * "日期一列 + 时刻一列" —— 分开存就表达不了"今天 22:30 到明天 01:15", * 而且用户填的时候也得分两处。全天日程仍然写 DATETIME,只是时间部分是 00:00:00。 * ⚠️ uid 一定要在**插入时**生成:CalDAV 客户端靠 UID 认日程, * 没有 UID 的日程每同步一次就被当成新日程再建一条(无限重复)。 */ public static function addEvent(int $calendarId, array $d): int { $db = Database::get(); $db->prepare( 'INSERT INTO calendar_events (calendar_id, uid, title, location, starts_at, ends_at, all_day, reminder_minutes, status, transp, rrule, note, created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)' )->execute([ $calendarId, self::newUid(), mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'), self::textOrNull((string)($d['location'] ?? ''), 255), $d['starts_at'], $d['ends_at'] ?: null, !empty($d['all_day']) ? 1 : 0, self::reminderMinutes($d['reminder_minutes'] ?? null), self::statusOf($d['status'] ?? null), !empty($d['transp']) ? 1 : 0, self::textOrNull((string)($d['rrule'] ?? ''), 255), self::textOrNull($d['note'] ?? ''), $d['created_by'] ?: null, ]); return (int)$db->lastInsertId(); } /** * 改一条日程。 * * ⚠️ SEQUENCE 每次 +1(RFC 5545):CalDAV 客户端拿它判断"服务端这版比我新吗"。 * 不加的话,改完日程客户端可能还抱着旧的不放。 */ public static function updateEvent(int $id, array $d): void { Database::get()->prepare( 'UPDATE calendar_events SET title=?, location=?, starts_at=?, ends_at=?, all_day=?, reminder_minutes=?, status=?, transp=?, rrule=?, note=?, sequence = sequence + 1 WHERE id=?' )->execute([ mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'), self::textOrNull((string)($d['location'] ?? ''), 255), $d['starts_at'], $d['ends_at'] ?: null, !empty($d['all_day']) ? 1 : 0, self::reminderMinutes($d['reminder_minutes'] ?? null), self::statusOf($d['status'] ?? null), !empty($d['transp']) ? 1 : 0, self::textOrNull((string)($d['rrule'] ?? ''), 255), self::textOrNull($d['note'] ?? ''), $id, ]); } public static function deleteEvent(int $id): void { Attachment::removeAll(self::ATTACH, $id); Database::get()->prepare('DELETE FROM calendar_events WHERE id = ?')->execute([$id]); } /** 一条日程(含所属本子;用来判权限) */ public static function event(int $id): ?array { $st = Database::get()->prepare( 'SELECT e.*, c.owner_id, c.name AS cal_name, c.color AS cal_color, c.kind AS cal_kind FROM calendar_events e JOIN calendars c ON c.id = e.calendar_id WHERE e.id = ?' ); $st->execute([$id]); $r = $st->fetch(); return $r ?: null; // 「能不能写」另走 canWriteEvent()(要按人查共享表,塞不进这一行) } /** * 这条日程我能写吗(按它所在本子的归属与共享权限判)。 * * ⚠️ 单独开一个方法而不是让调用方自己拼数组给 canWrite(): * canWrite() 吃的是「日历本」那一行(要带 perm),拿日程行直接喂进去 * 会因为没有 perm 而恒判成不能写。 */ public static function canWriteEvent(array $event, int $personId): bool { if ((int)($event['owner_id'] ?? 0) === $personId) { return true; } $st = Database::get()->prepare( 'SELECT perm FROM calendar_shares WHERE calendar_id = ? AND person_id = ?' ); $st->execute([(int)$event['calendar_id'], $personId]); return (string)$st->fetchColumn() === 'write'; } // ---------------- 重复日程的「某一次」例外(用户 2026-10-03 第 5 条)---------------- /** 把某一天标记为"跳过"(取消这一次 / 这一次被挪走) */ public static function skipOnce(int $eventId, string $dateYmd): void { $d = substr(trim($dateYmd), 0, 10); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) { return; } Database::get()->prepare( 'INSERT INTO calendar_event_skips (event_id, skip_date) VALUES (?,?) ON DUPLICATE KEY UPDATE skip_date = VALUES(skip_date)' )->execute([$eventId, $d]); } /** 取消"跳过"标记(撤销一次取消) */ public static function unskipOnce(int $eventId, string $dateYmd): void { Database::get()->prepare('DELETE FROM calendar_event_skips WHERE event_id = ? AND skip_date = ?') ->execute([$eventId, substr(trim($dateYmd), 0, 10)]); } /** 这条日程被跳过了哪些日期 */ public static function skipsOf(int $eventId): array { $st = Database::get()->prepare( 'SELECT skip_date FROM calendar_event_skips WHERE event_id = ? ORDER BY skip_date' ); $st->execute([$eventId]); return array_map(static fn($r) => (string)$r['skip_date'], $st->fetchAll()); } /** * 把"重复日程里被改掉的某一次"落成一条**独立的单次日程**。 * * 为什么要独立一条而不是改原日程:原日程是**整个系列**的定义, * 「仅此一次」的改动不能碰它。所以: * ① 在原系列里把这一天标记为跳过(不再出现原样那次); * ② 新建一条只发生这一次的日程,内容 = 用户改后的。 * 这样"改过的那一天"与"其余各次"各自独立,之后怎么改都不会互相干扰。 * * @return int 新建的那条日程 id */ public static function detachOnce(array $ev, string $date, array $d, int $byId = 0): int { $date = substr(trim($date), 0, 10); self::skipOnce((int)$ev['id'], $date); // 时刻:沿用用户改后的;他改的是"那一天的"日程,日期必须锁在 $date $starts = (string)($d['starts_at'] ?? ''); $ends = (string)($d['ends_at'] ?? ''); $startDay = substr($starts, 0, 10); if ($startDay !== $date) { // 用户没改日期(或者改到别的天了)→ 把这一天对齐到 $date, // 只取他改的时刻部分;否则"仅此一次"会莫名其妙跳走一天。 $starts = $date . ' ' . (substr($starts, 11, 8) !== '' ? substr($starts, 11, 8) : '00:00:00'); } $ends = $ends !== '' ? $date . ' ' . (substr($ends, 11, 8) ?: '00:00:00') : null; $new = $d; $new['starts_at'] = $starts; $new['ends_at'] = $ends; $new['rrule'] = ''; // 单次日程不带重复规则 // ⚠️ addEvent 内部会自己 newUid(),别在这里另设 $new['uid'](会被忽略, // 只是让人误以为"UID 是我指定的")。created_by 走 $d['created_by']。 $new['created_by'] = $byId ?: ($d['created_by'] ?? null); return self::addEvent((int)$ev['calendar_id'], $new); } /** 一条日程的 UID(CalDAV 那边按 UID 取) */ public static function newUid(): string { return bin2hex(random_bytes(12)) . '@familybook'; } /** * 导出 ics 用的行集(用户 2026-10-02 第 7 条)。 * * 三本日历的内容来源不一样,但**导出时都要有东西**: * · custom → calendar_events 里真存着的自定义日程; * · movie → events(kind=movie) 派生; * · trip → trip_nodes 派生(与日历视图同一口径:出节点,不出整趟旅程)。 * 统一成 calendar_events 那一套字段名(starts_at/ends_at/title/...), * Ics 那边就不必分辨来源是哪种。 * * @return array */ public static function exportRows(array $cal, int $familyId, int $me): array { $kind = (string)$cal['kind']; if ($kind === 'movie' || $kind === 'trip') { // 派生本:先按一个足够宽的范围取日历视图,再把每一条映射成可导出的行 $from = '1970-01-01'; $to = '2100-01-01'; $out = []; $seen = []; foreach (self::month($familyId, [$cal], $me, $from, $to) as $e) { $key = $e['kind'] . '-' . $e['ref_id'] . '-' . $e['date']; if (isset($seen[$key])) { continue; // 跨天日程会逐天出一条,导出时一条就够 } $seen[$key] = true; $day = (string)$e['date']; $out[] = [ 'uid' => $e['kind'] . '-' . (int)$e['ref_id'] . '@familybook', 'title' => (string)$e['title'], 'starts_at' => $day . ' ' . ($e['time'] !== '' ? $e['time'] . ':00' : '00:00:00'), 'ends_at' => null, 'all_day' => empty($e['time']) ? 1 : 0, 'location' => null, 'note' => null, 'status' => 'confirmed', 'transp' => 0, 'sequence' => 0, 'created_at' => null, 'updated_at' => null, 'reminder_minutes' => null, 'rrule' => null, ]; } return $out; } $st = Database::get()->prepare( 'SELECT * FROM calendar_events WHERE calendar_id = ? ORDER BY starts_at' ); $st->execute([(int)$cal['id']]); return $st->fetchAll(); } /** * 导入一条 ics 里的 VEVENT。 * * ⚠️ 判重用 UID + 日历本:同一本日历里 UID 已存在就**更新**而不是再插一条 —— * 否则同一个 .ics 导入两次,日历里就是两份一模一样的日程(用户最常见的操作 * "导一次没看清,再导一次"会立刻翻车)。 * * @return string 'added' | 'updated' */ public static function importEvent(int $calendarId, array $v, int $createdBy): string { $db = Database::get(); $uid = (string)($v['uid'] ?? ''); if ($uid !== '') { $st = $db->prepare('SELECT id FROM calendar_events WHERE calendar_id = ? AND uid = ?'); $st->execute([$calendarId, $uid]); $old = (int)$st->fetchColumn(); if ($old > 0) { self::updateEvent($old, $v); return 'updated'; } } else { $uid = self::newUid(); } $db->prepare( 'INSERT INTO calendar_events (calendar_id, uid, title, location, starts_at, ends_at, all_day, reminder_minutes, status, transp, rrule, note, created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)' )->execute([ $calendarId, $uid, mb_substr(trim((string)($v['title'] ?? '')), 0, 200, 'UTF-8'), self::textOrNull((string)($v['location'] ?? ''), 255), $v['starts_at'], $v['ends_at'] ?: null, !empty($v['all_day']) ? 1 : 0, self::reminderMinutes($v['reminder_minutes'] ?? null), self::statusOf($v['status'] ?? null), 0, self::textOrNull((string)($v['rrule'] ?? ''), 255), self::textOrNull((string)($v['note'] ?? '')), $createdBy ?: null, ]); return 'added'; } /** 提醒分钟数:只认预设档位与 0/NULL,乱传的落 NULL(不提醒) */ private static function reminderMinutes($v): ?int { if ($v === null || $v === '' || $v === false) { return null; } $n = (int)$v; return isset(self::REMINDERS[$n]) ? $n : null; } private static function statusOf($v): string { $v = (string)$v; return in_array($v, ['confirmed', 'tentative', 'cancelled'], true) ? $v : 'confirmed'; } /** * 某个月(或某段日期)里**全部**日程: * 自定义日程(calendar_events)+ 观影(events kind=movie)+ 旅行(events kind=trip) * + 有截止时间的任务(tasks.due_at)。 * * 统一成一种形状返回,模板不必分辨来源: * ['cal'=>日历本id, 'color'=>本子颜色, 'title', 'date'=>'Y-m-d', 'time'=>'HH:MM'|'', * 'all_day'=>bool, 'kind'=>'custom|movie|trip|task', 'ref_id'=>宿主id, 'href'=>点进去的地址] * * ⚠️ 观影/旅行只显示**本家庭**的记录(与观影模块同一个口径:能登录就能看)。 */ public static function month(int $familyId, array $calendars, int $personId, string $from, string $to): array { $out = []; $byKind = []; foreach ($calendars as $c) { $byKind[(string)$c['kind']][] = $c; } // ---- 自定义日程 ---- $ids = []; foreach ($calendars as $c) { if ((string)$c['kind'] === 'custom') { $ids[] = (int)$c['id']; } } if ($ids) { $in = implode(',', array_fill(0, count($ids), '?')); // ⚠️ 取数范围要**比 [from,to] 放宽**:重复日程的种子(starts_at)可能远早于 // 本月("从去年起每周三"),按 starts_at 直接卡范围会把整个系列漏掉。 // 这里取到 `to` 为止、往前不设限,展开阶段再按 [from,to] 裁。 $st = Database::get()->prepare( "SELECT * FROM calendar_events WHERE calendar_id IN ($in) AND DATE(starts_at) <= ? ORDER BY starts_at" ); $st->execute(array_merge($ids, [$to])); $calById = []; foreach ($calendars as $c) { $calById[(int)$c['id']] = $c; } $rows = $st->fetchAll(); // 一次把这批日程的「例外日期」取出来(N+1 会在重复日程多时明显变慢) $skips = []; if ($rows) { $eids = array_map('intval', array_column($rows, 'id')); $in2 = implode(',', array_fill(0, count($eids), '?')); $sk = Database::get()->prepare( "SELECT event_id, skip_date FROM calendar_event_skips WHERE event_id IN ($in2)" ); $sk->execute($eids); foreach ($sk->fetchAll() as $r) { $skips[(int)$r['event_id']][(string)$r['skip_date']] = true; } } foreach ($rows as $e) { $c = $calById[(int)$e['calendar_id']] ?? null; $sd = substr((string)$e['starts_at'], 0, 10); $ed = substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10); $tm = substr((string)$e['starts_at'], 11, 5); $rr = (string)($e['rrule'] ?? ''); // 用户 2026-10-03 第 5/6 条:重复日程按规则展开成若干次发生; // 不重复的走同一条路(occurrences 会返回它自己那一天)。 $days = Repeat::occurrences( (string)$e['starts_at'], $rr, $from, $to, $skips[(int)$e['id']] ?? [] ); foreach ($days as $day) { // 跨天日程:从这次发生的当天起,按事件本身的长度铺开 $span = (int)floor((strtotime($ed) - strtotime($sd)) / 86400); $first = true; foreach (self::eachDay($day, date('Y-m-d', strtotime($day . ' +' . $span . ' day')), $from, $to) as $d2) { $out[] = [ 'cal' => (int)$e['calendar_id'], 'color' => (string)($c['color'] ?? '#4A7C82'), 'calname' => (string)($c['name'] ?? ''), 'title' => (string)$e['title'], 'date' => $d2, // 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻 'time' => ($first && empty($e['all_day'])) ? $tm : '', 'all_day' => !empty($e['all_day']), 'kind' => 'custom', 'ref_id' => (int)$e['id'], // 带上这一天的日期,重复日程点进去才能定位到"改哪一次" 'occ' => $day, 'repeat' => $rr, 'href' => url('calendar/event', ['id' => (int)$e['id'], 'd' => $day]), ]; $first = false; } } } } // ---- 观影(events kind=movie)---- if (isset($byKind['movie'])) { $c = $byKind['movie'][0]; $st = Database::get()->prepare( "SELECT id, title, starts_at, ends_at FROM events WHERE family_id = ? AND kind = 'movie' AND starts_at IS NOT NULL AND DATE(starts_at) BETWEEN ? AND ? ORDER BY starts_at" ); $st->execute([$familyId, $from, $to]); foreach ($st->fetchAll() as $e) { $day = substr((string)$e['starts_at'], 0, 10); $t = substr((string)$e['starts_at'], 11, 5); $out[] = [ 'cal' => (int)$c['id'], 'color' => (string)$c['color'], 'calname' => (string)$c['name'], 'title' => '🎬 ' . (string)$e['title'], 'date' => $day, 'time' => $t === '00:00' ? '' : $t, 'all_day' => $t === '00:00', 'kind' => 'movie', 'ref_id' => (int)$e['id'], 'href' => url('movies/show', ['id' => (int)$e['id']]), ]; } } // ---- 旅程(trip_nodes,不是 events)---- // ⚠️ v24(用户 2026-10-02 第 2 条):日历里出的是**行程节点**,不是「这趟旅程」本身。 // 原来把整趟旅程摊成一条跨天全天日程,于是日历上一整周都被"云南之行"占满, // 真正要看的"几点出发 / 几点到景点"全都看不见 —— 旅程是个容器,节点才是日程。 // 现在按 trip_nodes.starts_at 出条目,跨天的节点同样逐天铺开。 if (isset($byKind['trip'])) { $c = $byKind['trip'][0]; // 用户 2026-10-03 第 4 条:不公开的旅程,节点也不上日历。 // COALESCE 兜底:缺 trip_details 行时按"公开"处理(老数据不会凭空消失)。 // ⚠️ 别在 SQL 字符串里写 `--` 行内注释:PHP 里那段是普通字符串, // 但 `--` 之后的内容仍是字符串的一部分,SQL 侧被注释掉, // 而 PHP 侧语法检查看不出问题 —— 会变成"语句少半截"的静默错误。 $st = Database::get()->prepare( "SELECT n.id AS node_id, n.title AS node_title, n.starts_at, n.ends_at, n.node_type, n.status AS node_status, e.id AS event_id, e.title AS trip_title FROM trip_nodes n JOIN events e ON e.id = n.event_id AND e.kind = 'trip' LEFT JOIN trip_details d ON d.event_id = e.id WHERE n.family_id = ? AND n.starts_at IS NOT NULL AND n.status <> 'cancelled' AND COALESCE(d.is_public, 1) = 1 AND DATE(n.starts_at) <= ? AND COALESCE(DATE(n.ends_at), DATE(n.starts_at)) >= ? ORDER BY n.starts_at" ); $st->execute([$familyId, $to, $from]); foreach ($st->fetchAll() as $n) { $start = substr((string)$n['starts_at'], 0, 10); $end = substr((string)($n['ends_at'] ?: $n['starts_at']), 0, 10); // 节点跨天时只在**第一天**显示时间(后面的天是"还在进行",不是"又开始了") $tm = substr((string)$n['starts_at'], 11, 5); $first = true; foreach (self::eachDay($start, $end, $from, $to) as $day) { $out[] = [ 'cal' => (int)$c['id'], 'color' => (string)$c['color'], 'calname' => (string)$c['name'], 'title' => Trip::typeIcon((string)$n['node_type']) . ' ' . (string)$n['node_title'], 'date' => $day, 'time' => $first ? ($tm === '00:00' ? '' : $tm) : '', 'all_day' => $first && $tm === '00:00', 'kind' => 'trip', 'ref_id' => (int)$n['node_id'], 'href' => url('trips/show', ['id' => (int)$n['event_id']]) . '#node-' . (int)$n['node_id'], ]; $first = false; } } } // ---- 有截止时间的任务 ---- $st = Database::get()->prepare( "SELECT t.id, t.title, t.due_at, t.status FROM tasks t LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ? WHERE t.family_id = ? AND t.due_at IS NOT NULL AND t.status <> 'done' AND DATE(t.due_at) BETWEEN ? AND ? AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL) ORDER BY t.due_at" ); $st->execute([$personId, $familyId, $from, $to, $personId, $personId]); foreach ($st->fetchAll() as $t) { $day = substr((string)$t['due_at'], 0, 10); $tm = substr((string)$t['due_at'], 11, 5); $out[] = [ 'cal' => 0, 'color' => '#A8574F', 'calname' => '任务', 'title' => '☑ ' . (string)$t['title'], 'date' => $day, 'time' => $tm === '00:00' ? '' : $tm, 'all_day' => $tm === '00:00', 'kind' => 'task', 'ref_id' => (int)$t['id'], 'href' => url('tasks/show', ['id' => (int)$t['id']]), ]; } // ---- 纪念日(v24 第 13 条)---- // 用户明确要求:纪念日一律作为**全天日程**出现在日历里。 // 数据是**实时派生**的(生日读 persons、农历逐年换算),所以这里不查表、直接算。 // ⚠️ 农历那一路必须按"这一年"换算 —— 同一条农历生日在不同年份落在不同公历日期。 foreach (Anniversary::between($familyId, $personId, $from, $to) as $a) { $out[] = [ 'cal' => 0, 'color' => '#B4633C', 'calname' => '纪念日', 'title' => '🎂 ' . (string)$a['title'], 'date' => (string)$a['date'], 'time' => '', 'all_day' => true, // 全天(用户点名要求) 'kind' => 'anniv', 'ref_id' => (int)($a['id'] ?? 0), 'href' => $a['id'] > 0 ? url('days/edit', ['id' => (int)$a['id']]) : url('days'), ]; } usort($out, static function ($a, $b) { return strcmp($a['date'], $b['date']) ?: strcmp((string)$a['time'], (string)$b['time']) ?: strcmp($a['title'], $b['title']); }); return $out; } /** 把 [start, end] 夹到 [from, to] 里,逐天列出(含端点) */ private static function eachDay(string $start, string $end, string $from, string $to): array { $s = max($start, $from); $e = min($end, $to); if ($s > $e) { return []; } $out = []; $cur = $s; for ($i = 0; $i < 400 && $cur <= $e; $i++) { $out[] = $cur; $cur = date('Y-m-d', strtotime($cur . ' +1 day')); } return $out; } private static function nextPosition(int $ownerId): int { $st = Database::get()->prepare('SELECT COALESCE(MAX(position),0)+10 FROM calendars WHERE owner_id = ?'); $st->execute([$ownerId]); return (int)$st->fetchColumn(); } private static function textOrNull(string $v): ?string { $v = trim($v); return $v !== '' ? $v : null; } }