['观影', '#7A4E8C'], 'trip' => ['旅行', '#3F7D5A'], ]; /** * 建本时的预设色板(与影院管理同一个交互:取色器 + 点一下就换)。 * * ⚠️ 一律挑**中低饱和**的:月网格里日程条是**白字压在色块上**, * 太浅看不清字,太艳会盖过整个月视图。 */ public const PRESET_CAL_COLORS = [ '#4A7C82', '#7A4E8C', '#3F7D5A', '#B0763A', '#5566A8', '#A8574F', '#8A6B4B', '#5C8A48', '#9A5C7A', '#3D6B8E', '#8C7A3F', '#6B5B95', ]; /** 新建一本 */ 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'; } /** 给某人补建两本默认日历(幂等) */ 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]); } // ---------------- 日程 ---------------- public static function addEvent(int $calendarId, array $d): int { $db = Database::get(); $db->prepare( 'INSERT INTO calendar_events (calendar_id, title, starts_at, ends_at, start_time, end_time, all_day, note, created_by) VALUES (?,?,?,?,?,?,?,?,?)' )->execute([ $calendarId, mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'), $d['starts_at'], $d['ends_at'] ?: null, $d['start_time'] ?: null, $d['end_time'] ?: null, !empty($d['all_day']) ? 1 : 0, self::textOrNull($d['note'] ?? ''), $d['created_by'] ?: null, ]); return (int)$db->lastInsertId(); } public static function updateEvent(int $id, array $d): void { Database::get()->prepare( 'UPDATE calendar_events SET title=?, starts_at=?, ends_at=?, start_time=?, end_time=?, all_day=?, note=? WHERE id=?' )->execute([ mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'), $d['starts_at'], $d['ends_at'] ?: null, $d['start_time'] ?: null, $d['end_time'] ?: null, !empty($d['all_day']) ? 1 : 0, self::textOrNull($d['note'] ?? ''), $id, ]); } public static function deleteEvent(int $id): void { Database::get()->prepare('DELETE FROM calendar_events WHERE id = ?')->execute([$id]); } /** * 某个月(或某段日期)里**全部**日程: * 自定义日程(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), '?')); $st = Database::get()->prepare( "SELECT * FROM calendar_events WHERE calendar_id IN ($in) AND starts_at <= ? AND COALESCE(ends_at, starts_at) >= ? ORDER BY starts_at, start_time" ); $st->execute(array_merge($ids, [$to, $from])); $calById = []; foreach ($calendars as $c) { $calById[(int)$c['id']] = $c; } foreach ($st->fetchAll() as $e) { $c = $calById[(int)$e['calendar_id']] ?? null; $end = $e['ends_at'] ?: $e['starts_at']; // 跨天日程:在范围内**每天**都出一条(群晖日历就是这么画的) foreach (self::eachDay((string)$e['starts_at'], (string)$end, $from, $to) as $day) { $out[] = [ 'cal' => (int)$e['calendar_id'], 'color' => (string)($c['color'] ?? '#4A7C82'), 'calname' => (string)($c['name'] ?? ''), 'title' => (string)$e['title'], 'date' => $day, 'time' => empty($e['all_day']) ? substr((string)$e['start_time'], 0, 5) : '', 'all_day' => !empty($e['all_day']), 'kind' => 'custom', 'ref_id' => (int)$e['id'], 'href' => url('calendar/event', ['id' => (int)$e['id']]), ]; } } } // ---- 观影(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']]), ]; } } // ---- 旅行(events kind=trip)---- if (isset($byKind['trip'])) { $c = $byKind['trip'][0]; $st = Database::get()->prepare( "SELECT id, title, starts_at, ends_at FROM events WHERE family_id = ? AND kind = 'trip' AND starts_at IS NOT NULL AND DATE(starts_at) <= ? AND COALESCE(DATE(ends_at), DATE(starts_at)) >= ? ORDER BY starts_at" ); $st->execute([$familyId, $to, $from]); foreach ($st->fetchAll() as $e) { foreach (self::eachDay(substr((string)$e['starts_at'], 0, 10), substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10), $from, $to) as $day) { $out[] = [ 'cal' => (int)$c['id'], 'color' => (string)$c['color'], 'calname' => (string)$c['name'], 'title' => '🧳 ' . (string)$e['title'], 'date' => $day, 'time' => '', 'all_day' => true, 'kind' => 'trip', 'ref_id' => (int)$e['id'], 'href' => url('trips/show', ['id' => (int)$e['id']]), ]; } } } // ---- 有截止时间的任务 ---- $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']]), ]; } 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; } }