405 lines
17 KiB
PHP
405 lines
17 KiB
PHP
<?php
|
||||
|
|
/**
|
|||
|
|
* 日历 / 日程(v14 新模块,导航条上叫「日程」)。
|
|||
|
|
*
|
|||
|
|
* 三条核心设计(用户 2026-10-01 第 7 条逐条对应):
|
|||
|
|
* ① **每人可建日历本**(自定义名字 + 自定义颜色),并能共享给别人(只读 / 可写)。
|
|||
|
|
* ② **观影与旅行是每人默认的两本**,名字就叫「观影」「旅行」,内容**从 events 派生** ——
|
|||
|
|
* 不复制一份进 calendar_events,否则"改了观影记录、日历还是旧的"。
|
|||
|
|
* 反过来,在日历里给观影本新增日程 = 写一条 events(kind='movie')(真的成为一条观影记录)。
|
|||
|
|
* ③ **没有具体时分的日程 = 全天日程**(all_day=1)。
|
|||
|
|
*
|
|||
|
|
* 日历其实是"综合视图":自定义日程 + 观影 + 旅行 + 有截止时间的任务,
|
|||
|
|
* 各模块各自出数据,日历只负责按日期摆出来(见 Calendar::month())。
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
declare(strict_types=1);
|
|||
|
|
|
|||
|
|
namespace App;
|
|||
|
|
|
|||
|
|
final class Calendar
|
|||
|
|
{
|
|||
|
|
/** 系统预置的两本默认日历(kind 与名字都写死,用户的自定义本一律 kind='custom') */
|
|||
|
|
public const DEFAULT_KINDS = [
|
|||
|
|
'movie' => ['观影', '#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;
|
|||
|
|
}
|
|||
|
|
}
|