用户 14 条需求一次交付(1-13 功能 + 14 工作方式)。 观影(1-4) - 返回文案全模块统一为「返回观影」(不再有「返回列表」「返回观影记录」三种叫法) - 观影人一律用彩色人物标签 person_chip:底色由头像主色算出来(上传时存库), 瀑布流卡片也按人显示;五处观影模板全部改用它 - 人员显示统一走 person_name()(shown → 我给他起的称呼 → 姓名) - 影院管理页与年度报告页的上下两块用 .card + .card 统一给间距(原来紧贴成一整块) 家谱(5) - 树根从「我」换成**辈分最高的祖先**:库画的是"树根的祖先链+后代+树根的兄弟姐妹", 根=我 时伯父/堂兄/嫂子/侄子一个都不画(12 人夹具只画出 8 人) - focusMap 按**代数**圈定三代(不再按步数),normalizeLinks 把子女对称挂到父母双方 —— 修掉"母亲登录看不到孩子" - 新增「看全部 / 回到我」两个按钮,放在画布外(画布整块被库接管了手势) 统一事件原语(6-9) - 旅程:TripLog 全量迁入(3 趟旅程 / 21 段行程 / 72 张照片 / 5 个人)。 复用 events(kind='trip') + trip_details + trip_nodes(计划/执行双轨保留) - 日程:每人可建日历本(自定义色)+ 共享只读/可写;观影与旅行是每人默认两本, 内容是**从各模块实时派生**的(不复制,所以改了观影日期日历立刻跟着变); 没有具体时分 = 全天;月视图参照群晖 Calendar - 资源:挂在某人名下的附属信息(标题+正文+多附件),可共享给多人并设只读/读写, 默认折叠、可搜标题与正文 - 任务:看板三列 + 两级子任务 + 评论@人 + 共享 + 循环(完成时按原截止日推下一期) - 统一附件表:四个模块的附件共用一张,下载地址不落库、一律由 media.php 现算 模块化(10-11) - App\Module 是模块的唯一来源(加一行 = 导航条/主题色/个人设置三处自动出现) - 个人设置里可勾选要显示哪些模块、拖动排序(关掉的模块**不会**被兜底补回来) - 每个模块一套主题色(家庭级),只覆盖 --brand 三兄弟,不碰纸墨底色 其他(12-13) - 新增成员表单可选所属分组(编辑表单没有:分组是"我和他的关系",只归我自己) - 首页改 dashboard:接下来的日子 / 未来七天 / 手头在做 / 往年今日。 家人卡片从首页撤走(看人去名册) 验证(全部通过) - e2e 542 条(新增 4 模块端到端 60+ 条) - 桌面几何 368 / 移动端几何 520 / 家谱 12 / 交互 97 / 老库升级 123 - SQL 列名静态校验 0 处可疑(对着真实表结构) - 反向验证 18 条新注入全部抓住 顺带修掉的既有问题 - 老库升级回填 SQL 按实际存在的列动态拼(缺列不再 1054 炸断升级) - 全新安装时 Schema::ensure 跑在"人还没建"之前 → 补建日历本三处齐备 - 窄屏导航横向溢出(模块从 4 个加到 8 个后顶栏撑破视口)
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;
|
||
}
|
||
}
|