diff --git a/app/Attachment.php b/app/Attachment.php new file mode 100644 index 0000000..a7c3f9c --- /dev/null +++ b/app/Attachment.php @@ -0,0 +1,171 @@ + 现算(地址形态换过一次, + * 历史数据自动生效、零迁移 —— 这是 FeedLite 那边学到的最省事的一条)。 + */ + +declare(strict_types=1); + +namespace App; + +use App\Storage\Storage; + +final class Attachment +{ + /** 图片扩展名 → MIME(服务端自己判,不信浏览器给的类型) */ + private const IMG_MIME = [ + 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', + 'gif' => 'image/gif', 'webp' => 'image/webp', 'heic' => 'image/heic', + ]; + + public const MAX_PER_HOST = 60; + + /** 某个宿主的全部附件 */ + public static function list(string $type, int $targetId): array + { + $st = Database::get()->prepare( + 'SELECT * FROM attachments WHERE target_type = ? AND target_id = ? ORDER BY position, id' + ); + $st->execute([$type, $targetId]); + return $st->fetchAll(); + } + + /** + * 存一个上传来的文件。 + * + * @return array{ok:bool,error?:string,id?:int} + */ + public static function save(int $familyId, string $type, int $targetId, array $file, int $uploaderId = 0): array + { + if (!array_key_exists('error', $file)) { + return ['ok' => false, 'error' => '']; + } + if (is_array($file['error'])) { + return ['ok' => false, 'error' => '上传参数不对']; + } + if ($file['error'] === UPLOAD_ERR_NO_FILE) { + return ['ok' => false, 'error' => '']; + } + if ($file['error'] !== UPLOAD_ERR_OK) { + return ['ok' => false, 'error' => '上传失败(错误码 ' . (int)$file['error'] . ')']; + } + $stc = Database::get()->prepare('SELECT COUNT(*) FROM attachments WHERE target_type = ? AND target_id = ?'); + $stc->execute([$type, $targetId]); + if ((int)$stc->fetchColumn() >= self::MAX_PER_HOST) { + return ['ok' => false, 'error' => '单个条目最多 ' . self::MAX_PER_HOST . ' 个附件']; + } + + $tmp = (string)$file['tmp_name']; + $size = (int)@filesize($tmp); + $fino = new \finfo(FILEINFO_MIME_TYPE); + $mime = (string)$fino->file($tmp); + $name = self::safeName((string)($file['name'] ?? '')); + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + if ($ext === '' && isset(self::IMG_MIME[$mime])) { + $ext = explode('/', $mime)[1] ?? ''; + } + // 图片走 photo 槽位(ratio='free':不裁剪,等比缩到最长边 1600); + // 非图片(PDF/文档)原样存 —— 压缩反而会把它弄坏。 + // ⚠️ 槽位键必须是 ImageSlot::SLOTS 里真实存在的那个:曾经这里写 'free' + // (把"不裁剪"的 ratio 当成槽位名用了),于是 Upload::image 直接返回 + // 「上传位置配置不正确」,所有附件上传静默失败。 + $isImage = strpos($mime, 'image/') === 0; + if ($isImage) { + $up = Upload::image($familyId, $file, 'photo'); + if (empty($up['ok'])) { + return ['ok' => false, 'error' => (string)($up['error'] ?? '存图失败')]; + } + $key = (string)$up['file']; + } else { + $key = 'files/' . bin2hex(random_bytes(12)) . ($ext !== '' ? '.' . preg_replace('/[^a-z0-9]/', '', $ext) : ''); + $res = Storage::putFile($familyId, $tmp, $key, $mime !== '' ? $mime : 'application/octet-stream'); + if (empty($res['ok'])) { + return ['ok' => false, 'error' => '保存失败:' . (string)($res['error'] ?? '')]; + } + } + + $db = Database::get(); + $db->prepare( + 'INSERT INTO attachments + (family_id, target_type, target_id, `key`, name, mime, size, is_image, position, uploader_id) + VALUES (?,?,?,?,?,?,?,?,?,?)' + )->execute([ + $familyId, $type, $targetId, $key, + $name !== '' ? $name : null, + $mime !== '' ? $mime : null, + $size > 0 ? $size : null, + $isImage ? 1 : 0, + self::nextPosition($type, $targetId), + $uploaderId > 0 ? $uploaderId : null, + ]); + return ['ok' => true, 'id' => (int)$db->lastInsertId()]; + } + + /** 删一个附件(连存储对象一起) */ + public static function remove(int $id): void + { + $st = Database::get()->prepare('SELECT family_id, `key` FROM attachments WHERE id = ?'); + $st->execute([$id]); + $r = $st->fetch(); + if (!$r) { + return; + } + Database::get()->prepare('DELETE FROM attachments WHERE id = ?')->execute([$id]); + Storage::delete((int)$r['family_id'], (string)$r['key']); + } + + /** 删某个宿主的全部附件(删旅程 / 删资源 / 删任务时调) */ + public static function removeAll(string $type, int $targetId): void + { + foreach (self::list($type, $targetId) as $a) { + self::remove((int)$a['id']); + } + } + + /** 能在浏览器里直接预览吗(图片 / PDF) */ + public static function previewable(array $a): bool + { + if ((int)($a['is_image'] ?? 0) === 1) { + return true; + } + return strpos((string)($a['mime'] ?? ''), 'pdf') !== false; + } + + /** 人话的大小 */ + public static function sizeWord($bytes): string + { + $b = (int)$bytes; + if ($b <= 0) { + return ''; + } + if ($b < 1024) { + return $b . ' B'; + } + if ($b < 1024 * 1024) { + return round($b / 1024, 1) . ' KB'; + } + return round($b / 1024 / 1024, 1) . ' MB'; + } + + private static function safeName(string $n): string + { + $n = str_replace(['\\', '/', ':', '*', '?', '"', '<', '>', '|'], '', $n); + $n = trim(preg_replace('/\s+/u', ' ', $n)); + return mb_substr($n, 0, 180, 'UTF-8'); + } + + private static function nextPosition(string $type, int $targetId): int + { + $st = Database::get()->prepare( + 'SELECT COALESCE(MAX(position),0)+10 FROM attachments WHERE target_type = ? AND target_id = ?' + ); + $st->execute([$type, $targetId]); + return (int)$st->fetchColumn(); + } +} diff --git a/app/Auth.php b/app/Auth.php index 7c6e744..a3e0b4d 100644 --- a/app/Auth.php +++ b/app/Auth.php @@ -155,6 +155,23 @@ final class Auth Database::get()->prepare('UPDATE accounts SET last_login_at = NOW() WHERE id = ?') ->execute([$row['account_id']]); + // 进门就把「这个人的默认日历本」补上(幂等)。 + // + // ⚠️ 为什么必须在这里兜一道,而不是只靠 Schema::ensure(): + // 全新安装时 install.php 是**先** Schema::ensure()、**后**建人 —— + // ensure() 里那段 seedDefaultsForFamily() 遍历的是"已开通账号的人", + // 那时一个人都还没有,于是**一本日历都不建**;紧接着 schema_version + // 被写成最新版,升级路径以后也不会再跑 → 这个人永远没有观影/旅行两本。 + // 症状很隐蔽:日程页能用,只是"我的日历本"空着,观影也不上日历。 + // 放在登录处最稳:不管人是装的、别人加的、还是老库升级来的,进来就有。 + try { + \App\Calendar::seedDefaults((int)$row['family_id'], (int)$row['person_id']); + } catch (\Throwable $ex) { + // 日历本建不出来不该把人挡在门外;下次登录会再试一次 + error_log('[familybook] 补建默认日历本失败(已跳过):' . $ex->getMessage() + . ' @ ' . $ex->getFile() . ':' . $ex->getLine()); + } + return true; } diff --git a/app/AvatarColor.php b/app/AvatarColor.php new file mode 100644 index 0000000..307f9a1 --- /dev/null +++ b/app/AvatarColor.php @@ -0,0 +1,292 @@ + 64 || $h > 64) { + $scale = min(64 / $w, 64 / $h); + $sw = max(1, (int)round($w * $scale)); + $sh = max(1, (int)round($h * $scale)); + $tmp = imagecreatetruecolor($sw, $sh); + if (!$tmp) { + return ''; + } + imagecopyresampled($tmp, $im, 0, 0, 0, 0, $sw, $sh, $w, $h); + $small = $tmp; + $w = $sw; + $h = $sh; + } + + $buckets = []; // hue桶 => [sumR, sumG, sumB, weight] + for ($y = 0; $y < $h; $y++) { + for ($x = 0; $x < $w; $x++) { + $rgb = imagecolorat($small, $x, $y); + $r = ($rgb >> 16) & 0xFF; + $g = ($rgb >> 8) & 0xFF; + $b = $rgb & 0xFF; + $mx = max($r, $g, $b); + $mn = min($r, $g, $b); + $chroma = $mx - $mn; + if ($chroma < self::CHROMA_MIN) { + continue; // 灰/白/黑:不参与 + } + if ($mx > 246 || $mn < 12) { + continue; // 死白 / 死黑:多半是背景或阴影 + } + $hue = self::hueOf($r, $g, $b, $mx, $mn, $chroma); + $bucket = (int)floor($hue / 10); + if ($bucket >= 36) { + $bucket = 35; + } + // 权重 = 色度(越鲜艳越能代表这张图)× 一个"别太暗也别太亮"的系数 + $weight = $chroma * (1 - abs(($mx + $mn) / 510 - 0.5)); + if (!isset($buckets[$bucket])) { + $buckets[$bucket] = [0, 0, 0, 0.0]; + } + $buckets[$bucket][0] += $r * $weight; + $buckets[$bucket][1] += $g * $weight; + $buckets[$bucket][2] += $b * $weight; + $buckets[$bucket][3] += $weight; + } + } + if ($small !== $im) { + imagedestroy($small); + } + if (!$buckets) { + return ''; + } + // 取权重最大的桶 + $best = null; + $bestW = -1.0; + foreach ($buckets as $b) { + if ($b[3] > $bestW) { + $bestW = $b[3]; + $best = $b; + } + } + if ($best === null || $best[3] <= 0) { + return ''; + } + return sprintf('#%02X%02X%02X', + (int)round($best[0] / $best[3]), + (int)round($best[1] / $best[3]), + (int)round($best[2] / $best[3])); + } + + /** 从图片二进制里取主色(OSS/本地都行);失败返回 '' */ + public static function fromBinary(string $bin): string + { + if ($bin === '') { + return ''; + } + $im = @imagecreatefromstring($bin); + if ($im === false) { + return ''; + } + $hex = self::fromGd($im); + imagedestroy($im); + return $hex; + } + + /** + * 姓名兜底色:同样的人永远同一个颜色。 + * + * 色相由姓名的 md5 决定(36 个色相里挑一个),饱和度与亮度**写死**在好看的区间内 + * —— 不用哈希去碰 S/L,那样会随机出刺眼的荧光色或看不清的深色。 + */ + public static function fromName(string $name): string + { + $name = trim($name); + $seed = $name === '' ? '?' : $name; + // ⚠️ 色相取 **0.1° 精度**(3600 档)而不是 360 档:派生出来的底色只保留色相 + // (S/L 都被 bg()/fg() 拉到固定区间),所以两个人要能区分,只能靠色相。 + // 360 档时十几个人里就撞色(实测:7 个名字里有两组落到同一个色)。 + // 干脆用 md5 的整个 32 位当连续色相,不再分档。 + $hue = hexdec(substr(md5($seed), 0, 8)) / 4294967296.0; + return self::hslToHex($hue, 0.55, 0.45); + } + + /** + * 一个人的展示色:库里算过就用库里的,没算过按姓名兜底。 + * 页面上一律走这里 —— 不许各自 `$p['avatar_color'] ?: '#888'`。 + */ + public static function for(?array $p): string + { + if (!$p) { + return self::fromName(''); + } + $c = self::normalize($p['avatar_color'] ?? null); + if ($c !== '') { + return $c; + } + return self::fromName((string)($p['name'] ?? '')); + } + + /** 规整成 '#rrggbb';认不出来返回 '' */ + public static function normalize($hex): string + { + $hex = strtolower(trim((string)$hex)); + if ($hex === '') { + return ''; + } + if ($hex[0] === '#') { + $hex = substr($hex, 1); + } + if (preg_match('/^[0-9a-f]{3}$/', $hex)) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + if (!preg_match('/^[0-9a-f]{6}$/', $hex)) { + return ''; + } + return '#' . $hex; + } + + /** + * 背景色:把主色往白里兑。 + * + * 兑完统一到 L≈0.90、S≈0.55 —— 不管头像是深蓝还是亮黄, + * 出来的都是"淡淡的那种蓝/黄",深色字压在上面都读得清, + * 同时彼此之间又能一眼区分(这正是用户要的效果)。 + */ + public static function bg(?string $hex): string + { + [$h, $s] = self::hsl(self::normalize($hex) ?: self::fromName('')); + $s = $s < 0.18 ? 0.30 : min(0.62, max(0.34, $s)); + return self::hslToHex($h, $s, 0.90); + } + + /** 文字色:把主色往黑里压,保证在 bg() 上有足够对比 */ + public static function fg(?string $hex): string + { + [$h, $s] = self::hsl(self::normalize($hex) ?: self::fromName('')); + $s = $s < 0.18 ? 0.35 : min(0.75, max(0.40, $s)); + return self::hslToHex($h, $s, 0.30); + } + + /** 边线色(比 bg 深一档,让浅色标签在白底上仍有轮廓) */ + public static function bd(?string $hex): string + { + [$h, $s] = self::hsl(self::normalize($hex) ?: self::fromName('')); + $s = $s < 0.18 ? 0.35 : min(0.70, max(0.38, $s)); + return self::hslToHex($h, $s, 0.72); + } + + // ---------------- 颜色空间小工具 ---------------- + + /** 0..360 的色相 */ + private static function hueOf(int $r, int $g, int $b, int $mx, int $mn, int $chroma): float + { + if ($chroma === 0) { + return 0.0; + } + if ($mx === $r) { + $h = fmod((($g - $b) / $chroma), 6.0); + } elseif ($mx === $g) { + $h = (($b - $r) / $chroma) + 2.0; + } else { + $h = (($r - $g) / $chroma) + 4.0; + } + $h *= 60.0; + if ($h < 0) { + $h += 360.0; + } + return $h; + } + + /** @return array{0:float,1:float,2:float} h(0..1) s(0..1) l(0..1) */ + private static function hsl(string $hex): array + { + $hex = ltrim($hex, '#'); + $r = hexdec(substr($hex, 0, 2)) / 255; + $g = hexdec(substr($hex, 2, 2)) / 255; + $b = hexdec(substr($hex, 4, 2)) / 255; + $mx = max($r, $g, $b); + $mn = min($r, $g, $b); + $l = ($mx + $mn) / 2; + $d = $mx - $mn; + if ($d == 0) { + return [0.0, 0.0, $l]; + } + // 分母在 l=0(死黑)/ l=1(死白)时为 0 —— 那时饱和度本来就没有意义,记 0。 + $den = 1 - abs(2 * $l - 1); + $s = $den > 1e-9 ? $d / $den : 0.0; + $h = self::hueOf( + (int)round($r * 255), (int)round($g * 255), (int)round($b * 255), + (int)round($mx * 255), (int)round($mn * 255), max(1, (int)round($d * 255)) + ); + return [$h / 360.0, $s, $l]; + } + + private static function hslToHex(float $h, float $s, float $l): string + { + $h = $h - floor($h); + if ($s <= 0) { + $v = (int)round($l * 255); + return sprintf('#%02X%02X%02X', $v, $v, $v); + } + $q = $l < 0.5 ? $l * (1 + $s) : $l + $s - $l * $s; + $p = 2 * $l - $q; + $conv = static function (float $t) use ($p, $q): float { + $t = $t - floor($t); + if ($t < 1 / 6) { + return $p + ($q - $p) * 6 * $t; + } + if ($t < 1 / 2) { + return $q; + } + if ($t < 2 / 3) { + return $p + ($q - $p) * (2 / 3 - $t) * 6; + } + return $p; + }; + $r = (int)round($conv($h + 1 / 3) * 255); + $g = (int)round($conv($h) * 255); + $b = (int)round($conv($h - 1 / 3) * 255); + return sprintf('#%02X%02X%02X', max(0, min(255, $r)), max(0, min(255, $g)), max(0, min(255, $b))); + } +} diff --git a/app/Calendar.php b/app/Calendar.php new file mode 100644 index 0000000..6d110dd --- /dev/null +++ b/app/Calendar.php @@ -0,0 +1,404 @@ + ['观影', '#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; + } +} diff --git a/app/Kinship.php b/app/Kinship.php index 4614e43..da40be5 100644 --- a/app/Kinship.php +++ b/app/Kinship.php @@ -62,14 +62,21 @@ final class Kinship return $out; } - /** 父母的 id 列表(不含 null),判断「跟我有关系」时用 */ + /** + * 父母的 id 列表(不含 null),判断「跟我有关系」时用。 + * + * ⚠️ v14:这里**直接读图里的边**,不再绕 `parents()` 那对 father/mother 槽位。 + * 绕槽位会**丢人**:`parents()` 是按角色往两个槽里填的,一旦数据里 + * 同一个角色被记了两个人(典型:母亲那条边漏填 from_role,于是"父亲"这个 + * 槽被母亲覆盖/挤掉),`parentIds()` 就只剩一个人 —— + * 下游的家谱图于是只剩一位家长,**母亲那边整条线消失**(正是用户报的那个 + * 「母亲登录进去看不到自己的孩子」的另一种成因)。 + */ public static function parentIds(array $g, int $id): array { $out = []; - foreach (self::parents($g, $id) as $pid) { - if ($pid !== null) { - $out[] = $pid; - } + foreach (array_keys($g['parents'][$id] ?? []) as $pid) { + $out[] = (int)$pid; } return $out; } @@ -380,6 +387,17 @@ final class Kinship return $pos === false ? 1 : $pos + 1; } + /** + * 排序用的键(对外版)。 + * + * 家谱页要按它给同一代排长幼,而模板拿不到私有方法 —— 与其在模板里各写一套 + * 「birth_rank → birthday → id」的兜底(写一次就会跟这里不一致),不如开个口子。 + */ + public static function rankValueFor(array $people, int $id): string + { + return self::rankValue($people, $id); + } + /** 排序用的键:显式 birth_rank 优先,否则用生日,再否则用 id */ private static function rankValue(array $people, int $id): string { @@ -452,6 +470,123 @@ final class Kinship // ================= 家谱渲染所需的结构化数据 ================= + /** + * v14:**以我为中心,把有关系的都检索出来**,只保留「上一代 / 这一代 / 下一代」。 + * + * 为什么重写(用户 2026-10-01 第 5 条,原话「显示不齐全…应该以本人为中心去搜索, + * 只要有关系的,都检索出来进行展示」): + * 旧写法是「从我出发上下各 N 步」的**步数**限制,而真正要的是**代数**限制。 + * 两者在「横向」关系上差很远:我的堂兄弟要先**上一步**到伯父、**再下一步**才到, + * 两步就耗尽了配额,于是「这一代」里只剩亲兄弟姐妹,堂表亲全不见了。 + * 改成按代数筛选之后,横向绕多远都不算远 —— 只要落回这三代里就要。 + * + * 三条边都要走(parent / child / spouse),缺一条就会漏人: + * · 只走上下 → 配偶、嫂子弟媳、姐夫妹夫全没了; + * · 不走配偶 → 连「我配偶的兄弟姐妹」都到不了(要先经过配偶这个人)。 + * + * ⚠️ 用 BFS + $gen 防环:数据脏了(互为父母、自己给自己当父母)不会死循环。 + * ⚠️ 只把「落在 [-up, +down] 里的人」入队 —— 超出三代的不展开, + * 但仍要允许**经过**上一代去够到同代的堂表亲(所以筛选的是代数,不是步数)。 + * + * @return array personId => 相对我的代数(-1 上一代 / 0 这一代 / +1 下一代) + */ + public static function focusMap(array $g, int $me, array $people, int $up = 1, int $down = 1): array + { + if (!isset($people[$me])) { + return []; + } + $gen = [$me => 0]; + $queue = [$me]; + while ($queue) { + $id = array_shift($queue); + $lv = (int)$gen[$id]; + $add = static function (int $nid, int $nlv) use (&$gen, &$queue, $people, $up, $down): void { + if ($nlv < -$up || $nlv > $down) { + return; // 落在三代之外:不收、也不从它继续展开 + } + if (!isset($people[$nid]) || isset($gen[$nid])) { + return; // 名册里没有 / 已经有更靠前的走法 + } + $gen[$nid] = $nlv; + $queue[] = $nid; + }; + // 配偶永远同代 + foreach (self::spouseIds($g, $id) as $sp) { + $add((int)$sp, $lv); + } + foreach (self::parentIds($g, $id) as $pid) { + $add((int)$pid, $lv - 1); + } + foreach (self::childIds($g, $id) as $cid) { + $add((int)$cid, $lv + 1); + } + } + return $gen; + } + + /** + * v14:**子女必须挂在「夫妻」上,而不是挂在其中一个人身上**。 + * + * 用户点名的重大 bug(原话):「如果从一个出来,例如从父亲出来,母亲登录进去之后 + * 竟然看不到自己的孩子」。根因不是显示,是**数据**:只要某个孩子的 parents 只写了 + * 父亲一个人,那张图对母亲来说就是"别人的孩子"。 + * + * 所以这里做一次**对称补齐**:父母双方互为配偶时,孩子同时挂到两个人身上, + * 两个人的 children 里也都有这个孩子。库(family-chart)在「孩子有两位父母、 + * 且这两位互为配偶」时,会把连线从**夫妻之间那条横线**的中点拉下来 —— + * 这正是用户要的效果,也顺手解决了"母亲看不到孩子"。 + * + * ⚠️ 只补**真实存在的**父/母(relations 里有的边),绝不凭空造人。 + * + * @param array $ids 图上会出现的人(过滤悬空引用用) + * @return array{parents:array,children:array,spouses:array} + */ + public static function normalizeLinks(array $g, array $ids): array + { + $onTree = array_fill_keys(array_map('intval', array_values($ids)), true); + $parents = []; + $children = []; + $spouses = []; + + foreach (array_keys($onTree) as $id) { + $id = (int)$id; + // ---- 配偶 ---- + $sp = []; + foreach (self::spouseIds($g, $id) as $s) { + $s = (int)$s; + if (isset($onTree[$s]) && !in_array($s, $sp, true)) { + $sp[] = $s; + } + } + $spouses[$id] = $sp; + + // ---- 父母 ---- + // ⚠️ 一律取**图里所有的 parent 边**,绝不只取 father/mother 两个槽位 —— + // 槽位会丢人(见 parentIds 的注释)。 + $pa = []; + foreach (self::parentIds($g, $id) as $p) { + $p = (int)$p; + if (isset($onTree[$p]) && !in_array($p, $pa, true)) { + $pa[] = $p; + } + } + $parents[$id] = $pa; + } + + // ---- 子女:由 parents 反推,天然对称 ---- + foreach ($parents as $child => $pa) { + foreach ($pa as $p) { + $children[$p][] = (int)$child; + } + } + foreach ($children as $k => $v) { + $v = array_values(array_unique(array_map('intval', $v))); + usort($v, fn($a, $b) => $a <=> $b); + $children[$k] = $v; + } + return ['parents' => $parents, 'children' => $children, 'spouses' => $spouses]; + } + /** * 以 $me 为中心,向上向下各展开若干代,产出可渲染的家谱树。 * diff --git a/app/Module.php b/app/Module.php new file mode 100644 index 0000000..6f8b67e --- /dev/null +++ b/app/Module.php @@ -0,0 +1,307 @@ + [导航条上的名字, 默认主题色, 路由] + * + * ⚠️ key 必须等于路由名(r=xxx):导航链接、pageKey 高亮、「本页属于哪个模块」 + * 全都按 key 走,各写一份迟早对不上。 + * ⚠️ 主题色一律挑**中低饱和、中低明度**的:按钮上是白字,太浅会看不清, + * 太艳会盖过内容(家书整体是宣纸米 + 砖红的暖色调,模块色只是分区,不是主色)。 + */ + public const LIST = [ + 'home' => ['首页', '#A84A28', 'home'], + 'kin' => ['家谱', '#8A6B4B', 'kin'], + 'people' => ['名册', '#4A7C82', 'people'], + 'movies' => ['观影', '#7A4E8C', 'movies'], + 'trips' => ['旅程', '#3F7D5A', 'trips'], + 'calendar' => ['日程', '#B0763A', 'calendar'], + 'resources' => ['资源', '#5566A8', 'resources'], + 'tasks' => ['任务', '#A8574F', 'tasks'], + ]; + + /** 默认出现在导航条上的(没自定义过就用它)。首页永远在,且不能被拖到最后面也不影响。 */ + public const DEFAULT_NAV = ['home', 'kin', 'people', 'movies', 'trips', 'calendar', 'resources', 'tasks']; + + /** + * 主题色的预设色板(个人设置里点一下就换)。 + * + * ⚠️ 一律**中低饱和、中低明度**:cssVars() 会把主色压到 L≈0.42 当按钮底色、 + * L≈0.28 当字色 —— 太浅则白字在按钮上看不清,太艳则盖过纸墨底的本色。 + */ + public const PRESET_COLORS = [ + '#A84A28', '#8A6B4B', '#4A7C82', '#7A4E8C', '#3F7D5A', + '#B0763A', '#5566A8', '#A8574F', '#5C8A48', '#6B5B95', + '#9A5C7A', '#3D6B8E', + ]; + + /** 模块名(认不出的 key 返回空串,调用方自己兜底) */ + public static function name(string $key): string + { + return self::LIST[$key][0] ?? ''; + } + + public static function route(string $key): string + { + return self::LIST[$key][2] ?? $key; + } + + /** 模块是否存在(个人设置里的开关、路由白名单都靠它,别再用 in_array 手写列表) */ + public static function exists(string $key): bool + { + return isset(self::LIST[$key]); + } + + /** 全部模块 key(按 LIST 的顺序) */ + public static function keys(): array + { + return array_keys(self::LIST); + } + + // ---------------- 导航条:每人一份 ---------------- + + /** + * 这个人导航条上要显示哪些模块、什么顺序。 + * + * ⚠️ 两道兜底,缺一不可: + * ① 存过的值里**只保留还存在的模块** —— 删模块/改 key 之后, + * 老配置里那条会变成一个认不出的 key,直接渲染就是一条空链接; + * ② LIST 里**新增的模块补到末尾** —— 否则"新装的模块在导航条上永远不出现", + * 用户会以为功能没做(这正是加这张表的动机)。 + */ + public static function navFor(int $personId): array + { + $raw = (string)UserSetting::get($personId, 'nav_modules', ''); + $dec = $raw !== '' ? json_decode($raw, true) : null; + if (!is_array($dec)) { + return self::DEFAULT_NAV; // 没配过 → 默认那一套(新装的人、重置过的) + } + $saved = []; + foreach ($dec as $k) { + $k = (string)$k; + if (self::exists($k) && !in_array($k, $saved, true)) { + $saved[] = $k; + } + } + // ⚠️ 「新装的模块要自动出现」**不能**用"不在 saved 里就补"来实现 —— + // 那样用户**主动关掉**的模块下一次请求就会被补回来, + // 「可设置哪些模块出现在导航条」直接失效(实测:关掉观影,刷新它又回来了)。 + // 所以另存一份 nav_known =「用户做这次决定时,世上已知的全部模块」: + // 只有 `现在的模块 - known` 才是**真的新装**、才该补进去。 + $known = []; + $kraw = (string)UserSetting::get($personId, 'nav_known', ''); + $kdec = $kraw !== '' ? json_decode($kraw, true) : null; + if (is_array($kdec)) { + foreach ($kdec as $k) { + $known[] = (string)$k; + } + } + foreach (self::keys() as $k) { + if (!in_array($k, $known, true)) { + $saved[] = $k; // 真·新装的模块:补到末尾,别让用户以为功能没做 + } + } + return $saved ?: self::DEFAULT_NAV; + } + + /** + * 保存导航配置。 + * + * ⚠️ 传进来的顺序**原样保存**(拖动排序要的就是顺序),但只收认识的 key: + * 前端可能把「已关闭的模块」也一起 POST 上来,混进去就是"关了还在"。 + */ + public static function saveNav(int $personId, array $keys): void + { + $out = []; + foreach ($keys as $k) { + $k = (string)$k; + if (self::exists($k) && !in_array($k, $out, true)) { + $out[] = $k; + } + } + if (!$out) { + $out = self::DEFAULT_NAV; + } + UserSetting::set($personId, 'nav_modules', json_encode($out, JSON_UNESCAPED_UNICODE)); + // 顺手记下「做这次决定时已知的全部模块」——navFor() 靠它区分 + // 「用户关掉的」与「后来新装的」(见那边的注释)。 + UserSetting::set($personId, 'nav_known', + json_encode(self::keys(), JSON_UNESCAPED_UNICODE)); + } + + // ---------------- 主题色:家庭一份 ---------------- + + /** key => '#rrggbb'(没配过就用 LIST 里的默认色) */ + public static function colors(int $familyId = 0): array + { + $out = []; + foreach (self::LIST as $k => $def) { + $out[$k] = $def[1]; + } + // ⚠️ $familyId=0(安装期 / 还没登录)时就用默认色,不要为此炸掉整页 + if ($familyId <= 0) { + return $out; + } + $raw = (string)Setting::get($familyId, 'module_colors', ''); + if ($raw === '') { + return $out; + } + $dec = json_decode($raw, true); + if (is_array($dec)) { + foreach ($dec as $k => $v) { + $v = AvatarColor::normalize($v); + if (isset($out[$k]) && $v !== '') { + $out[$k] = $v; + } + } + } + return $out; + } + + public static function color(string $key, int $familyId = 0): string + { + $c = self::colors($familyId); + return $c[$key] ?? ($c['home'] ?? '#A84A28'); + } + + /** + * 把主题色铺成三个 CSS 变量。 + * + * 为什么不只给一个 --brand:按钮的 hover(--brand-2)与文字(--brand-ink) + * 都是从主色推出来的深浅两档,只换主色会让 hover 变成别的颜色、或者 + * 白字压在浅底上看不清。这里统一按 HSL 压明度,保证三档永远同色系。 + * + * @return array{brand:string,brand2:string,ink:string} + */ + public static function cssVars(string $key, int $familyId = 0): array + { + $hex = self::color($key, $familyId); + [$h, $s] = self::hsl($hex); + $s = max(0.28, min(0.72, $s)); + return [ + 'brand' => self::hslToHex($h, $s, 0.42), + 'brand2' => self::hslToHex($h, $s, 0.58), + 'ink' => self::hslToHex($h, $s, 0.28), + ]; + } + + public static function saveColors(int $familyId, array $colors): void + { + $out = []; + foreach (self::keys() as $k) { + $v = AvatarColor::normalize($colors[$k] ?? null); + if ($v !== '') { + $out[$k] = $v; + } + } + Setting::set($familyId, 'module_colors', json_encode($out, JSON_UNESCAPED_UNICODE)); + } + + /** + * 「本页属于哪个模块」—— 主题色与导航高亮都靠它。 + * + * pageKey 可能是子页(movies/search、trips/show…),所以按**最长前缀**匹配到模块。 + */ + public static function ofPage(string $pageKey): string + { + if (self::exists($pageKey)) { + return $pageKey; + } + $best = ''; + foreach (self::keys() as $k) { + if (strpos($pageKey, $k . '/') === 0 || strpos($pageKey, $k . '_') === 0) { + if (strlen($k) > strlen($best)) { + $best = $k; + } + } + } + return $best; + } + + // ---------------- HSL 小工具(与 AvatarColor 同一套算法,只是参数不同) ---------------- + + /** @return array{0:float,1:float,2:float} */ + private static function hsl(string $hex): array + { + $hex = ltrim(AvatarColor::normalize($hex) ?: '#A84A28', '#'); + $r = hexdec(substr($hex, 0, 2)) / 255; + $g = hexdec(substr($hex, 2, 2)) / 255; + $b = hexdec(substr($hex, 4, 2)) / 255; + $mx = max($r, $g, $b); + $mn = min($r, $g, $b); + $l = ($mx + $mn) / 2; + $d = $mx - $mn; + if ($d == 0) { + return [0.0, 0.0, $l]; + } + $den = 1 - abs(2 * $l - 1); + $s = $den > 1e-9 ? $d / $den : 0.0; + $h = self::hue($r, $g, $b, $mx, $mn, $d); + return [$h, $s, $l]; + } + + private static function hue(float $r, float $g, float $b, float $mx, float $mn, float $d): float + { + if ($mx === $r) { + $h = fmod((($g - $b) / $d), 6.0); + } elseif ($mx === $g) { + $h = (($b - $r) / $d) + 2.0; + } else { + $h = (($r - $g) / $d) + 4.0; + } + $h *= 60.0; + return $h < 0 ? ($h + 360.0) / 360.0 : $h / 360.0; + } + + /** HSL → '#rrggbb'(与 AvatarColor::hslToHex 同算法,这里独立一份只为不暴露私有方法) */ + private static function hslToHex(float $h, float $s, float $l): string + { + $h = $h - floor($h); + if ($s <= 0) { + $v = (int)round($l * 255); + return sprintf('#%02X%02X%02X', $v, $v, $v); + } + $q = $l < 0.5 ? $l * (1 + $s) : $l + $s - $l * $s; + $p = 2 * $l - $q; + $conv = static function (float $t) use ($p, $q): float { + $t = $t - floor($t); + if ($t < 1 / 6) { + return $p + ($q - $p) * 6 * $t; + } + if ($t < 1 / 2) { + return $q; + } + if ($t < 2 / 3) { + return $p + ($q - $p) * (2 / 3 - $t) * 6; + } + return $p; + }; + $r = (int)round($conv($h + 1 / 3) * 255); + $g = (int)round($conv($h) * 255); + $b = (int)round($conv($h - 1 / 3) * 255); + return sprintf('#%02X%02X%02X', max(0, min(255, $r)), max(0, min(255, $g)), max(0, min(255, $b))); + } +} diff --git a/app/Movie.php b/app/Movie.php index f056674..8c82fe7 100644 --- a/app/Movie.php +++ b/app/Movie.php @@ -24,16 +24,17 @@ namespace App; final class Movie { /** @return array 观影记录,按开场时刻倒序(瀑布流用)。 - * $keyword 非空时搜:片名 / 影院名 / 同行人名(LIKE)。 */ - public static function all(int $familyId, string $keyword = ''): array + * $keyword 非空时搜:片名 / 影院名 / 同行人名(LIKE)。 + * + * ⚠️ 列表上的「观影人」是**每人一个标签**(头像 + 底色 + 称呼), + * 不再拼成一串名字 —— 所以这里用 attachAttendees() 补出 `attendees`, + * 而不是 SQL 里 GROUP_CONCAT 出一个 attendee_names 字符串。 */ + public static function all(int $familyId, string $keyword = '', int $viewerId = 0): array { $sql = 'SELECT e.id, e.title, e.starts_at, e.ends_at, e.note, d.poster_key, c.name AS cinema_name, c.color AS cinema_color, - (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS attendee_count, - (SELECT GROUP_CONCAT(p.name ORDER BY a.position, p.id SEPARATOR "、") - FROM event_attendees a JOIN persons p ON p.id = a.person_id - WHERE a.event_id = e.id) AS attendee_names + (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS attendee_count FROM events e LEFT JOIN movie_details d ON d.event_id = e.id LEFT JOIN cinemas c ON c.id = d.cinema_id @@ -52,11 +53,47 @@ final class Movie $st->execute($bind); $rows = $st->fetchAll(); MovieReview::attachStats($rows); + self::attachAttendees($rows, $viewerId); return $rows; } + /** + * 给一批记录补上 `attendees`(每人一条:id / name / my_nickname / avatar / + * avatar_color)。**一次查询**补完,不逐条查 —— 瀑布流一页几十部电影, + * 逐条查就是几十次往返(N+1)。 + */ + public static function attachAttendees(array &$rows, int $viewerId = 0): void + { + if (!$rows) { + return; + } + $ids = []; + foreach ($rows as $r) { + $ids[] = (int)$r['id']; + } + $ids = array_values(array_unique($ids)); + $in = implode(',', array_fill(0, count($ids), '?')); + $st = Database::get()->prepare( + "SELECT a.event_id, p.id, p.name, p.avatar, p.avatar_color, + n.nickname AS my_nickname + FROM event_attendees a + JOIN persons p ON p.id = a.person_id + LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? + WHERE a.event_id IN ($in) + ORDER BY a.position, p.id" + ); + $st->execute(array_merge([$viewerId], $ids)); + $map = []; + foreach ($st->fetchAll() as $r) { + $map[(int)$r['event_id']][] = $r; + } + foreach ($rows as $k => $r) { + $rows[$k]['attendees'] = $map[(int)$r['id']] ?? []; + } + } + /** 一条完整记录(详情/编辑页用):事件 + 卫星 + 影院 + 同行人 + 照片 */ - public static function find(int $familyId, int $id): ?array + public static function find(int $familyId, int $id, int $viewerId = 0): ?array { $st = Database::get()->prepare( 'SELECT e.*, d.info, d.poster_key, d.ticket_key, d.cinema_id, d.douban_meta, @@ -72,21 +109,25 @@ final class Movie if (!$row) { return null; } - $row['attendees'] = self::attendees($id); + $row['attendees'] = self::attendees($id, $viewerId); $row['photos'] = self::photos($id); return $row; } - /** @return array 同行的人 */ - public static function attendees(int $eventId): array + /** + * 同行的人。带 `my_nickname` 与 `avatar_color` —— 页面上要按「我叫他什么」 + * 显示、并用他自己的底色画出标签(见 helpers 的 person_name / person_bg)。 + */ + public static function attendees(int $eventId, int $viewerId = 0): array { $st = Database::get()->prepare( - 'SELECT p.id, p.name, p.avatar + 'SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname FROM event_attendees a JOIN persons p ON p.id = a.person_id + LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? WHERE a.event_id = ? ORDER BY a.position, p.id' ); - $st->execute([$eventId]); + $st->execute([$viewerId, $eventId]); return $st->fetchAll(); } diff --git a/app/MovieStats.php b/app/MovieStats.php index 03270d5..346874b 100644 --- a/app/MovieStats.php +++ b/app/MovieStats.php @@ -72,19 +72,20 @@ final class MovieStats return $st->fetchAll(); } - /** 按观影人:参与了多少场(带头像,供统计页画条) */ - public static function byPerson(int $familyId): array + /** 按观影人:参与了多少场(带头像,供统计页画条;名称按「我叫他什么」显示) */ + public static function byPerson(int $familyId, int $viewerId = 0): array { $st = Database::get()->prepare( - "SELECT p.id, p.name, p.avatar, COUNT(e.id) AS cnt + "SELECT p.id, p.name, p.avatar, n.nickname AS my_nickname, COUNT(e.id) AS cnt FROM persons p LEFT JOIN event_attendees a ON a.person_id = p.id LEFT JOIN events e ON e.id = a.event_id AND e.kind = 'movie' AND e.family_id = ? + LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? WHERE p.family_id = ? AND p.kind = 'member' - GROUP BY p.id, p.name, p.avatar + GROUP BY p.id, p.name, p.avatar, n.nickname ORDER BY cnt DESC, p.id" ); - $st->execute([$familyId, $familyId]); + $st->execute([$familyId, $viewerId, $familyId]); return $st->fetchAll(); } @@ -136,7 +137,7 @@ final class MovieStats * 高级搜索:片名 / 影院 / 观影人 / 日期范围 组合过滤。 * 条件全空时返回 [](页面据此提示「先设置条件」)。 */ - public static function search(int $familyId, array $p): array + public static function search(int $familyId, array $p, int $viewerId = 0): array { $title = trim((string)($p['title'] ?? '')); $cid = (int)($p['cinema_id'] ?? 0); @@ -185,6 +186,7 @@ final class MovieStats $st->execute($bind); $rows = $st->fetchAll(); MovieReview::attachStats($rows); + Movie::attachAttendees($rows, $viewerId); return $rows; } @@ -338,13 +340,14 @@ final class MovieStats $st->execute($args()); $list = $st->fetchAll(); $co = $db->prepare( - "SELECT p.id, p.name, p.avatar + "SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname FROM event_attendees a JOIN persons p ON p.id = a.person_id + LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? WHERE a.event_id = ? AND a.person_id <> ? ORDER BY a.position, p.id" ); foreach ($list as &$mv) { - $co->execute([(int)$mv['id'], $personId]); + $co->execute([$personId, (int)$mv['id'], $personId]); $mv['viewers'] = $co->fetchAll(); } unset($mv); diff --git a/app/Person.php b/app/Person.php index ef74c3e..60a2db2 100644 --- a/app/Person.php +++ b/app/Person.php @@ -287,6 +287,23 @@ final class Person ->execute([$avatar, $id]); } + /** + * 只改头像主色。 + * + * ⚠️ 空串**不写库**:算不出主色(灰片/纯色证件照)时应该让它保持 NULL, + * 让 AvatarColor::for() 落到「按姓名取色」的兜底上 —— + * 这时候才不会每次换图颜色乱跳。 + */ + public static function setAvatarColor(int $id, ?string $hex): void + { + $hex = AvatarColor::normalize($hex); + if ($hex === '') { + return; + } + Database::get()->prepare('UPDATE persons SET avatar_color = ? WHERE id = ?') + ->execute([$hex, $id]); + } + public static function delete(int $id): void { Database::get()->prepare('DELETE FROM persons WHERE id = ?')->execute([$id]); diff --git a/app/Resource.php b/app/Resource.php new file mode 100644 index 0000000..8eea49a --- /dev/null +++ b/app/Resource.php @@ -0,0 +1,202 @@ +,没有 JS 也能开合(app.js 不依赖它)。 + * + * ⚠️ 与「名册」的分工:名册记录的是**这个人是谁**(生日、电话、关系); + * 资源记录的是**这个人名下有什么东西**(证书、账号、照片)。不要混。 + */ + +declare(strict_types=1); + +namespace App; + +final class Resource +{ + /** 资源的宿主类型(附件表用) */ + public const ATTACH = 'resource'; + + /** + * 我能看到的资源 = 我名下的 + 别人共享给我的。 + * + * 返回的行里带 `perm`:'owner' / 'write' / 'read'(模板据此决定能不能改)。 + * + * @param int $personId 看的人(决定"我能看什么") + * @param int $ownerId 0=不限归属;非 0=只看这个人(且必须是我能看到的) + * @param string $keyword 标题 + 正文里搜 + */ + public static function all(int $familyId, int $personId, int $ownerId = 0, string $keyword = ''): array + { + $sql = 'SELECT r.*, p.name AS owner_name, p.avatar, p.avatar_color, + n.nickname AS owner_nick, + CASE WHEN r.owner_id = ? THEN "owner" + ELSE COALESCE((SELECT s.perm FROM resource_shares s + WHERE s.resource_id = r.id AND s.person_id = ?), "read") + END AS perm + FROM resources r + JOIN persons p ON p.id = r.owner_id + LEFT JOIN nicknames n ON n.target_id = r.owner_id AND n.viewer_id = ? + LEFT JOIN resource_shares sh ON sh.resource_id = r.id AND sh.person_id = ? + WHERE r.family_id = ? AND (r.owner_id = ? OR sh.person_id IS NOT NULL)'; + $bind = [$personId, $personId, $personId, $personId, $familyId, $personId]; + if ($ownerId > 0) { + $sql .= ' AND r.owner_id = ?'; + $bind[] = $ownerId; + } + $kw = trim($keyword); + if ($kw !== '') { + $sql .= ' AND (r.title LIKE ? OR r.content LIKE ?)'; + $like = '%' . $kw . '%'; + array_push($bind, $like, $like); + } + $sql .= ' ORDER BY r.updated_at DESC, r.id DESC'; + $st = Database::get()->prepare($sql); + $st->execute($bind); + return $st->fetchAll(); + } + + /** 一条资源(带归属人与我对它的权限;看不到返回 null) */ + public static function find(int $familyId, int $id, int $personId): ?array + { + $st = Database::get()->prepare( + 'SELECT r.*, p.name AS owner_name, p.avatar, p.avatar_color, + n.nickname AS owner_nick, + CASE WHEN r.owner_id = ? THEN "owner" + ELSE COALESCE((SELECT s.perm FROM resource_shares s + WHERE s.resource_id = r.id AND s.person_id = ?), "read") + END AS perm + FROM resources r + JOIN persons p ON p.id = r.owner_id + LEFT JOIN nicknames n ON n.target_id = r.owner_id AND n.viewer_id = ? + WHERE r.id = ? AND r.family_id = ?' + ); + $st->execute([$personId, $personId, $personId, $id, $familyId]); + $r = $st->fetch(); + if (!$r) { + return null; + } + // ⚠️ 权限在**这一层**判死:调用方拿到记录就一定是能看的。 + // 曾经想让模板自己判,结果是每条列表都要重复一遍 CASE WHEN。 + $mine = (int)$r['owner_id'] === $personId + || self::isShared((int)$r['id'], $personId); + if (!$mine) { + return null; + } + return $r; + } + + public static function canWrite(array $r, int $personId): bool + { + if ((int)($r['owner_id'] ?? 0) === $personId) { + return true; + } + return ($r['perm'] ?? '') === 'write'; + } + + public static function isShared(int $resourceId, int $personId): bool + { + $st = Database::get()->prepare( + 'SELECT 1 FROM resource_shares WHERE resource_id = ? AND person_id = ?' + ); + $st->execute([$resourceId, $personId]); + return $st->fetchColumn() !== false; + } + + /** @return int 新资源 id */ + public static function create(int $familyId, int $ownerId, ?int $createdBy, array $d): int + { + $db = Database::get(); + $db->prepare( + 'INSERT INTO resources (family_id, owner_id, title, content, created_by) VALUES (?,?,?,?,?)' + )->execute([ + $familyId, $ownerId, + self::title($d['title'] ?? ''), + self::textOrNull($d['content'] ?? ''), + $createdBy ?: null, + ]); + return (int)$db->lastInsertId(); + } + + public static function update(int $familyId, int $id, array $d): void + { + Database::get()->prepare( + 'UPDATE resources SET title = ?, content = ? WHERE id = ? AND family_id = ?' + )->execute([ + self::title($d['title'] ?? ''), + self::textOrNull($d['content'] ?? ''), + $id, $familyId, + ]); + } + + public static function destroy(int $familyId, int $id): void + { + // 附件表没有外键(target_type 表达不了),删宿主时自己清存储对象 + \App\Attachment::removeAll(self::ATTACH, $id); + Database::get()->prepare('DELETE FROM resources WHERE id = ? AND family_id = ?') + ->execute([$id, $familyId]); + } + + // ---------------- 共享 ---------------- + + public static function shares(int $resourceId): array + { + $st = Database::get()->prepare( + 'SELECT s.person_id, s.perm, p.name, p.avatar, p.avatar_color + FROM resource_shares s JOIN persons p ON p.id = s.person_id + WHERE s.resource_id = ? ORDER BY p.id' + ); + $st->execute([$resourceId]); + return $st->fetchAll(); + } + + /** + * 设共享。 + * + * ⚠️ owner 自己**不进**共享表 —— 他本来就有全部权限, + * 记进去只是多一条要维护的行(还会让"共享给谁"的列表里冒出自己)。 + */ + public static function setShare(int $resourceId, int $ownerId, int $personId, string $perm): void + { + if ($personId <= 0 || $personId === $ownerId) { + self::dropShare($resourceId, $personId); + return; + } + Database::get()->prepare( + 'INSERT INTO resource_shares (resource_id, person_id, perm) VALUES (?,?,?) + ON DUPLICATE KEY UPDATE perm = VALUES(perm)' + )->execute([$resourceId, $personId, $perm === 'write' ? 'write' : 'read']); + } + + public static function dropShare(int $resourceId, int $personId): void + { + Database::get()->prepare('DELETE FROM resource_shares WHERE resource_id = ? AND person_id = ?') + ->execute([$resourceId, $personId]); + } + + public static function permWord(string $perm): string + { + return $perm === 'owner' ? '本人' : ($perm === 'write' ? '可读写' : '只读'); + } + + // ---------------- 小工具 ---------------- + + private static function title(string $v): string + { + return mb_substr(trim($v), 0, 120, 'UTF-8'); + } + + private static function textOrNull(string $v): ?string + { + $v = trim($v); + return $v !== '' ? $v : null; + } +} diff --git a/app/Schema.php b/app/Schema.php index f1d89a1..3ba53a2 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -49,8 +49,23 @@ final class Schema * MovieLog 模型:movie_reviews(每账号每片一条,1-10 分 + 正文 + 感受) * + movie_review_comments(两级平铺讨论),movie_details 旧的单条 * rating/review/feelings 迁进 movie_reviews 后删列。 + * 10 → **豆瓣自动补充**:movie_details.douban_meta(JSON,元数据整体存一列)。 + * 11 → **头像主色**:persons.avatar_color(CHAR(7))。上传/换头像时算一次存库, + * 观影人标签等处按每人主色画底色(用户 2026-10-01 第 2 条); + * 老头像在升级时**回读一次**补算,补不到的按姓名兜底(AvatarColor)。 + * 12 → **四个新模块(用户 2026-10-01 第 6~10 条)**: + * ① 旅程:events.kind 加 trip + trip_details(封面/行程小结)+ trip_nodes + * (行程节点,计划/执行双轨)+ trip_node_members; + * ② 日程:calendars(每人可建、自定义颜色、可共享只读/可写) + * + calendar_events(自定义日程);观影/旅行是**每人默认的两本**, + * 内容从 events 派生、不重复落库; + * ③ 资源:resources(挂在某人名下)+ resource_shares(共享给谁、只读/读写); + * ④ 任务:tasks(两级:parent_id)+ task_shares + task_comments(支持 @人) + * + task_recur(灵活循环); + * ⑤ 统一附件 attachments(旅程/节点/资源/任务/评论共用一张表); + * ⑥ 模块导航与主题色走 user_settings / settings,不新建表。 */ - public const VERSION = '10'; + public const VERSION = '12'; /** @return string[] */ public static function statements(): array @@ -323,6 +338,204 @@ CREATE TABLE IF NOT EXISTS `event_photos` ( KEY `idx_ep_event` (`event_id`, `position`), CONSTRAINT `fk_ep_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='事件的现场照片(可多张)' +SQL, + // ---- 统一附件(v12)---- + // 旅程照片 / 资源附件 / 任务附件 / 评论附件,全都走这一张表: + // 下载、预览、权限判断只写一次;将来再加模块也不用再造一张附件表。 + // ⚠️ 不做外键(target_type 决定指向哪张表,外键表达不了), + // 只在 (target_type, target_id) 上建索引。删宿主时由各模块自己清。 + <<<'SQL' +CREATE TABLE IF NOT EXISTS `attachments` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `target_type` ENUM('trip','trip_node','resource','task','task_comment') NOT NULL COMMENT '挂在谁身上', + `target_id` INT UNSIGNED NOT NULL COMMENT '宿主的 id', + `key` VARCHAR(255) NOT NULL COMMENT '文件(存储相对键)', + `name` VARCHAR(200) DEFAULT NULL COMMENT '原始文件名(下载时回给它)', + `mime` VARCHAR(120) DEFAULT NULL COMMENT '真实 MIME(由服务端 finfo 判定,不信浏览器给的)', + `size` BIGINT UNSIGNED DEFAULT NULL COMMENT '字节数', + `is_image` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=图片,浏览器可直接预览', + `position` INT NOT NULL DEFAULT 100, + `uploader_id` INT UNSIGNED DEFAULT NULL COMMENT '谁传的(persons.id)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_att_target` (`target_type`, `target_id`, `position`), + CONSTRAINT `fk_att_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_att_uploader` FOREIGN KEY (`uploader_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一附件:旅程/资源/任务/评论共用' +SQL, + // ---- 旅程(v12,TripLog 迁入)---- + <<<'SQL' +CREATE TABLE IF NOT EXISTS `trip_details` ( + `event_id` INT UNSIGNED NOT NULL COMMENT '对应 events(kind="trip")', + `cover_key` VARCHAR(255) DEFAULT NULL COMMENT '封面图(存储相对键)', + `summary` TEXT DEFAULT NULL COMMENT '这趟旅程的一段话小结', + PRIMARY KEY (`event_id`), + CONSTRAINT `fk_tdet_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='旅程事件的特有数据(一对一卫星表)' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `trip_nodes` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `event_id` INT UNSIGNED NOT NULL COMMENT '属于哪趟旅程', + `category` ENUM('plan','reality') NOT NULL DEFAULT 'plan' COMMENT 'plan=计划 / reality=实际走过的', + `node_type` VARCHAR(20) NOT NULL DEFAULT 'other' COMMENT '游玩 / 用餐 / 赶路 / 住宿 / 其他', + `title` VARCHAR(200) NOT NULL, + `detail` TEXT DEFAULT NULL COMMENT '这一段的文字记录', + `place` VARCHAR(200) DEFAULT NULL COMMENT '地点', + `starts_at` DATETIME DEFAULT NULL, + `ends_at` DATETIME DEFAULT NULL, + `status` ENUM('planned','ongoing','done','cancelled') NOT NULL DEFAULT 'planned', + `position` INT NOT NULL DEFAULT 100 COMMENT '手动排序(拖动改的就是它)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_tn_event` (`event_id`, `category`, `position`), + KEY `idx_tn_time` (`starts_at`), + CONSTRAINT `fk_tn_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_tn_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程节点:一趟旅程里的每一段(计划/执行双轨,TripLog 同款)' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `trip_node_members` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `node_id` INT UNSIGNED NOT NULL, + `person_id` INT UNSIGNED NOT NULL COMMENT '这一段里有谁', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tnm_node_person` (`node_id`, `person_id`), + CONSTRAINT `fk_tnm_node` FOREIGN KEY (`node_id`) REFERENCES `trip_nodes` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_tnm_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程节点的参与人' +SQL, + // ---- 日程(v12)---- + <<<'SQL' +CREATE TABLE IF NOT EXISTS `calendars` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `owner_id` INT UNSIGNED NOT NULL COMMENT '这本日历是谁的', + `name` VARCHAR(50) NOT NULL, + `color` CHAR(7) NOT NULL DEFAULT '#4A7C82' COMMENT '日历本的颜色(日程按本子的颜色显示)', + `kind` ENUM('custom','movie','trip') NOT NULL DEFAULT 'custom' COMMENT 'movie/trip = 系统给每人预置的默认本,内容从 events 派生', + `position` INT NOT NULL DEFAULT 100, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cal_owner_kind` (`owner_id`, `kind`, `name`), + KEY `idx_cal_family` (`family_id`), + CONSTRAINT `fk_cal_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_cal_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='日历本:每人可建,可自定义颜色,可共享给别人' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `calendar_shares` ( + `calendar_id` INT UNSIGNED NOT NULL, + `person_id` INT UNSIGNED NOT NULL COMMENT '共享给谁', + `perm` ENUM('read','write') NOT NULL DEFAULT 'read' COMMENT 'read=只读 / write=可写(能新增与修改日程)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`calendar_id`, `person_id`), + CONSTRAINT `fk_cs_cal` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_cs_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='日历本共享:对方能看到这本日历(只读/可写)' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `calendar_events` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `calendar_id` INT UNSIGNED NOT NULL, + `title` VARCHAR(200) NOT NULL, + `starts_at` DATE NOT NULL COMMENT '开始日期', + `ends_at` DATE DEFAULT NULL COMMENT '结束日期(跨天日程;NULL=就一天)', + `start_time` TIME DEFAULT NULL COMMENT '开始时刻;**NULL 与 00:00:00 都表示全天**', + `end_time` TIME DEFAULT NULL, + `all_day` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=全天日程(没有具体时分)', + `note` TEXT DEFAULT NULL, + `created_by` INT UNSIGNED DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_ce_cal` (`calendar_id`, `starts_at`), + CONSTRAINT `fk_ce_cal` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_ce_author` FOREIGN KEY (`created_by`) REFERENCES `persons` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自定义日程(观影/旅行那两本不落这里,从 events 派生)' +SQL, + // ---- 资源(v12)---- + <<<'SQL' +CREATE TABLE IF NOT EXISTS `resources` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `owner_id` INT UNSIGNED NOT NULL COMMENT '这是**谁名下**的资源(如「我的身份证」)', + `title` VARCHAR(120) NOT NULL COMMENT '标题:身份证 / 学位证 / 一寸照片…', + `content` TEXT DEFAULT NULL COMMENT '正文(文字说明)', + `created_by` INT UNSIGNED DEFAULT NULL COMMENT '谁建的(本人自然是本人,别人代建时记一笔)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_res_owner` (`owner_id`, `id`), + CONSTRAINT `fk_res_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_res_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_res_author` FOREIGN KEY (`created_by`) REFERENCES `persons` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='挂在某人名下的资源信息(证件、照片…)+ 多附件' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `resource_shares` ( + `resource_id` INT UNSIGNED NOT NULL, + `person_id` INT UNSIGNED NOT NULL COMMENT '共享给谁(**不含** owner —— 名下的人本来就能看能改)', + `perm` ENUM('read','write') NOT NULL DEFAULT 'read', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`resource_id`, `person_id`), + CONSTRAINT `fk_rs_res` FOREIGN KEY (`resource_id`) REFERENCES `resources` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_rs_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='资源共享给别人:可只读、可读写' +SQL, + // ---- 任务(v12)---- + <<<'SQL' +CREATE TABLE IF NOT EXISTS `tasks` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `family_id` INT UNSIGNED NOT NULL, + `parent_id` INT UNSIGNED DEFAULT NULL COMMENT 'NULL=父任务;非空=某个父任务下的子任务(只做两级)', + `title` VARCHAR(200) NOT NULL, + `note` TEXT DEFAULT NULL, + `status` ENUM('todo','doing','done') NOT NULL DEFAULT 'todo' COMMENT '看板的三列', + `due_at` DATETIME DEFAULT NULL COMMENT '截止时间;**有截止时间才会出现在日历里**', + `position` INT NOT NULL DEFAULT 100 COMMENT '看板里的先后(拖动改的就是它)', + `creator_id` INT UNSIGNED DEFAULT NULL, + `assignee_id` INT UNSIGNED DEFAULT NULL COMMENT '主要负责人(可空:共享给谁谁都能完成)', + `repeat_rule` VARCHAR(40) DEFAULT NULL COMMENT '循环:day / week / month / year(空=不循环)', + `done_at` DATETIME DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_task_board` (`family_id`, `status`, `position`), + KEY `idx_task_parent` (`parent_id`), + KEY `idx_task_due` (`due_at`), + CONSTRAINT `fk_task_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_task_parent` FOREIGN KEY (`parent_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_task_creator` FOREIGN KEY (`creator_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_task_assignee` FOREIGN KEY (`assignee_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务:看板三列 + 两级子任务 + 评论 + 共享 + 循环' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `task_shares` ( + `task_id` INT UNSIGNED NOT NULL, + `person_id` INT UNSIGNED NOT NULL COMMENT '共享给谁(两个人里任何一个完成都算完成)', + `perm` ENUM('read','write') NOT NULL DEFAULT 'write', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`task_id`, `person_id`), + CONSTRAINT `fk_ts_task` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_ts_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务共享:任何人完成都算完成' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `task_comments` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `task_id` INT UNSIGNED NOT NULL, + `person_id` INT UNSIGNED NOT NULL, + `content` TEXT NOT NULL COMMENT '支持 @某人:渲染时把 @名字 高亮出来', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_tc_task` (`task_id`, `created_at`), + CONSTRAINT `fk_tc_task` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_tc_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务评论(支持 @人)' SQL, ]; } @@ -521,6 +734,31 @@ SQL, self::addColumn($db, 'movie_details', 'douban_meta', "ADD COLUMN `douban_meta` TEXT DEFAULT NULL COMMENT '豆瓣自动补充的元数据(JSON)' AFTER `cinema_id`"); + // ---- 头像主色(版本 11)---- + // 只在「上传/换头像」那一刻算一次(见 AvatarColor 的说明):渲染时再算 + // 就要为页面上的每个人回读一次存储,几十个人 = 几十次外网往返。 + self::addColumn($db, 'persons', 'avatar_color', + "ADD COLUMN `avatar_color` CHAR(7) DEFAULT NULL COMMENT '头像主色(上传时算一次;页面按它派生底色/字色)' AFTER `avatar`"); + // 老头像(v11 之前传的)补算一次:回读已存的图算主色。 + // 纯数据步骤 → 容错跑:某张图读不回来(OSS 权限、对象丢了)只影响那一个人, + // 他会自动落到「按姓名取色」的兜底上,不该把后面的升级步骤挡住。 + self::tryStep('回填头像主色', static function () use ($db) { + self::backfillAvatarColor($db); + }); + + // ---- 四个新模块(版本 12)---- + // ⚠️ events.kind 原本只有 'movie',旅程要复用统一事件原语就必须加 'trip'。 + // 建表语句的 IF NOT EXISTS 对**已存在的表**不会更新枚举 —— 老库必须显式改, + // 否则插入 kind='trip' 会被严格模式整条拒掉(v7 就踩过同一类坑)。 + self::widenEventKindEnum($db); + // 每人的两本默认日历(观影 / 旅行):新装的由 Calendar::seedDefaults 在 + // 建账号时建,老库在这里补(幂等:按 (owner_id, kind, name) 唯一键查过再插)。 + self::tryStep('补建每人默认的观影/旅行日历本', static function () use ($db) { + foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { + \App\Calendar::seedDefaultsForFamily((int)$fam['id']); + } + }); + // 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 —— // 不然「升级没跑完」这件事只活在日志里,跟没发生一样。 if (self::$stepErrors) { @@ -602,8 +840,22 @@ SQL, */ private static function backfillCinemasFromDetails(\PDO $db): void { + // ⚠️ 逐列守卫,而不是只信任调用方那一个判据(`hasColumn(cinema_name)`): + // 老库的版本千差万别,"有 cinema_name 但缺 cinema_place"是完全可能的, + // 那样下面这句 SELECT 会 1054(Unknown column)—— 而它**不在 tryStep 里** + // (回填是删列的唯一前提,炸出来才是对的),一次升级就断在这儿、 + // 下次请求再断一次。按实际存在的列动态拼查询,缺列就少搬一列,不炸。 + $cols = []; + foreach (['cinema_place', 'cinema_name', 'cinema_address'] as $c) { + if (self::hasColumn($db, 'movie_details', $c)) { + $cols[] = $c; + } + } + if (!$cols) { + return; + } $rows = $db->query( - 'SELECT d.event_id, e.family_id, d.cinema_place, d.cinema_name, d.cinema_address + 'SELECT d.event_id, e.family_id, d.' . implode(', d.', $cols) . ' FROM movie_details d JOIN events e ON e.id = d.event_id WHERE d.cinema_name IS NOT NULL AND d.cinema_name <> \'\'' )->fetchAll(); @@ -635,10 +887,24 @@ SQL, */ private static function backfillMovieReviewsFromDetails(\PDO $db): void { + // 同 backfillCinemasFromDetails:按实际存在的列动态拼,缺列就少搬一列, + // 绝不让"某个中间版本的老库正好缺一列"把整条升级路径炸断。 + $cols = []; + foreach (['rating', 'review', 'feelings'] as $c) { + if (self::hasColumn($db, 'movie_details', $c)) { + $cols[] = $c; + } + } + if (!in_array('rating', $cols, true)) { + return; // 连 rating 都没有 → 没有"评价"可迁 + } + $where = 'd.rating IS NOT NULL'; + if (in_array('review', $cols, true)) { + $where .= " OR (d.review IS NOT NULL AND d.review <> '')"; + } $rows = $db->query( - 'SELECT d.event_id, e.family_id, e.creator_id, d.rating, d.review, d.feelings - FROM movie_details d JOIN events e ON e.id = d.event_id - WHERE d.rating IS NOT NULL OR (d.review IS NOT NULL AND d.review <> \'\')' + 'SELECT d.event_id, e.family_id, e.creator_id, d.' . implode(', d.', $cols) . ' + FROM movie_details d JOIN events e ON e.id = d.event_id WHERE ' . $where )->fetchAll(); if (!$rows) { return; @@ -674,6 +940,45 @@ SQL, } } + /** + * v11:给「有头像但没主色」的人补算主色。 + * + * 幂等:条件是 `avatar_color IS NULL`,算过的人不会被重算(用户后来换的头像 + * 由上传流程负责更新,升级不该覆盖它)。 + * 读不回来就跳过 —— 那个人走 AvatarColor::for() 的姓名兜底,永远是同一种颜色。 + */ + private static function backfillAvatarColor(\PDO $db): void + { + $rows = $db->query( + "SELECT id, family_id, avatar, name FROM persons + WHERE avatar IS NOT NULL AND avatar <> '' AND avatar_color IS NULL + ORDER BY id" + )->fetchAll(); + if (!$rows) { + return; + } + $up = $db->prepare('UPDATE persons SET avatar_color = ? WHERE id = ?'); + foreach ($rows as $r) { + $key = trim((string)$r['avatar']); + if ($key === '') { + continue; + } + try { + $res = \App\Storage\Storage::read((int)$r['family_id'], $key); + } catch (\Throwable $e) { + continue; + } + if (empty($res['ok']) || (string)($res['body'] ?? '') === '') { + continue; + } + $hex = \App\AvatarColor::fromBinary((string)$res['body']); + if ($hex === '') { + continue; // 灰片:留给姓名兜底,不要塞一个飘忽的灰进去 + } + $up->execute([$hex, (int)$r['id']]); + } + } + /** 列不存在才加 */ private static function addColumn(\PDO $db, string $table, string $column, string $alter): void { @@ -723,6 +1028,24 @@ SQL, } } + /** events.kind 加 'trip'(旅程复用统一事件原语;见 ensure 里的说明) */ + private static function widenEventKindEnum(\PDO $db): void + { + $st = $db->prepare( + "SELECT COLUMN_TYPE FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'events' AND COLUMN_NAME = 'kind'" + ); + $st->execute(); + $type = (string)$st->fetchColumn(); + if ($type !== '' && strpos($type, 'trip') === false) { + $db->exec( + "ALTER TABLE `events` MODIFY COLUMN `kind` + ENUM('movie','trip') NOT NULL + COMMENT '事件类型:movie=观影 / trip=旅程(任务等只在有截止时间时上日历,不占 kind)'" + ); + } + } + private static function hasIndex(\PDO $db, string $table, string $index): bool { $st = $db->prepare( diff --git a/app/Task.php b/app/Task.php new file mode 100644 index 0000000..bf3e653 --- /dev/null +++ b/app/Task.php @@ -0,0 +1,654 @@ + '待办', 'doing' => '进行中', 'done' => '已完成']; + + public const REPEAT = [ + '' => '不循环', + 'day' => '每天', + 'week' => '每周', + 'month' => '每月', + 'year' => '每年', + ]; + + /** 附件的宿主类型 */ + public const ATTACH = 'task'; + + // ---------------- 读 ---------------- + + /** + * 我能看到的任务 = 我建的 + 分给我的 + 共享给我的。 + * + * 一次查出来,内存里按 status 分三堆(看板要的就是这三堆)。 + * ⚠️ 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。 + */ + public static function board(int $familyId, int $personId): array + { + $st = Database::get()->prepare( + 'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color, + na.nickname AS assignee_nick, + (SELECT COUNT(*) FROM tasks c WHERE c.parent_id = t.id) AS child_total, + (SELECT COUNT(*) FROM tasks c WHERE c.parent_id = t.id AND c.status = "done") AS child_done, + (SELECT COUNT(*) FROM task_comments m WHERE m.task_id = t.id) AS comment_count, + (SELECT COUNT(*) FROM attachments f WHERE f.target_type = "task" AND f.target_id = t.id) AS attach_count + FROM tasks t + LEFT JOIN persons a ON a.id = t.assignee_id + LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ? + LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ? + WHERE t.family_id = ? AND t.parent_id IS NULL + AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL) + ORDER BY t.position, t.id' + ); + $st->execute([$personId, $personId, $familyId, $personId, $personId]); + $rows = $st->fetchAll(); + + // 一次把全部子任务取出来挂上去(N+1 会让看板在几十条时明显变慢) + $children = self::childrenFor(array_map('intval', array_column($rows, 'id')), $personId); + foreach ($rows as &$r) { + $r['children'] = $children[(int)$r['id']] ?? []; + } + unset($r); + + $out = ['todo' => [], 'doing' => [], 'done' => []]; + foreach ($rows as $r) { + $k = (string)$r['status']; + $out[$k][] = $r; + } + return $out; + } + + /** @return array 父任务 id => 子任务列表 */ + public static function childrenFor(array $parentIds, int $viewerId = 0): array + { + $out = []; + $ids = array_values(array_filter(array_map('intval', $parentIds))); + if (!$ids) { + return $out; + } + $in = implode(',', array_fill(0, count($ids), '?')); + $st = Database::get()->prepare( + "SELECT c.*, a.name AS assignee_name, a.avatar, a.avatar_color, + na.nickname AS assignee_nick + FROM tasks c + LEFT JOIN persons a ON a.id = c.assignee_id + LEFT JOIN nicknames na ON na.target_id = c.assignee_id AND na.viewer_id = ? + WHERE c.parent_id IN ($in) ORDER BY c.position, c.id" + ); + $st->execute(array_merge([$viewerId], $ids)); + foreach ($st->fetchAll() as $c) { + $out[(int)$c['parent_id']][] = $c; + } + return $out; + } + + /** + * 一条任务(带子任务 / 评论 / 附件 / 共享)。看不到返回 null。 + */ + public static function find(int $familyId, int $id, int $personId): ?array + { + $st = Database::get()->prepare( + // ⚠️ 父任务的标题列叫 `title`,**不是 `name`** —— 这里写过 `p.name`, + // php -l 一点问题都没有(SQL 是字符串),只在真的打开任务详情页时 + // 抛 PDOException: Unknown column 'p.name',而它又被 PHP 以 + // "200 + Fatal error 正文"吐出来,看着像页面正常返回。 + // 改 SQL 之后一定要用「真实库表结构」校验列名(见 sql-schema-column-check)。 + 'SELECT t.*, p.title AS parent_title, + a.name AS assignee_name, a.avatar AS assignee_avatar, a.avatar_color AS assignee_color, + na.nickname AS assignee_nick, + cr.name AS creator_name, cr.avatar AS creator_avatar, cr.avatar_color AS creator_color, + nc.nickname AS creator_nick + FROM tasks t + LEFT JOIN tasks p ON p.id = t.parent_id + LEFT JOIN persons a ON a.id = t.assignee_id + LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ? + LEFT JOIN persons cr ON cr.id = t.creator_id + LEFT JOIN nicknames nc ON nc.target_id = t.creator_id AND nc.viewer_id = ? + WHERE t.id = ? AND t.family_id = ?' + ); + $st->execute([$personId, $personId, $id, $familyId]); + $t = $st->fetch(); + if (!$t) { + return null; + } + if (!self::visibleTo($t, $personId)) { + return null; + } + // 子任务的任务详情里要能看到父任务,父任务也要能看到全部兄弟 + $t['children'] = self::childrenFor([(int)$t['id']], $personId)[(int)$t['id']] ?? []; + $t['comments'] = self::comments((int)$t['id'], $personId); + $t['attachs'] = \App\Attachment::list(self::ATTACH, (int)$t['id']); + $t['shares'] = self::shares((int)$t['id']); + return $t; + } + + /** 我能看这条吗(建的 / 分给我的 / 共享给我的 / 父任务的子任务) */ + public static function visibleTo(array $t, int $personId): bool + { + if ((int)($t['creator_id'] ?? 0) === $personId || (int)($t['assignee_id'] ?? 0) === $personId) { + return true; + } + if (self::isShared((int)$t['id'], $personId)) { + return true; + } + // 子任务:父任务能看,子任务也就能看(否则点开父任务会看到一半的子任务) + $pid = (int)($t['parent_id'] ?? 0); + if ($pid > 0) { + $st = Database::get()->prepare( + 'SELECT creator_id, assignee_id FROM tasks WHERE id = ?' + ); + $st->execute([$pid]); + $p = $st->fetch(); + if ($p && ((int)$p['creator_id'] === $personId || (int)$p['assignee_id'] === $personId + || self::isShared($pid, $personId))) { + return true; + } + } + return false; + } + + public static function isShared(int $taskId, int $personId): bool + { + $st = Database::get()->prepare('SELECT 1 FROM task_shares WHERE task_id = ? AND person_id = ?'); + $st->execute([$taskId, $personId]); + return $st->fetchColumn() !== false; + } + + /** 我能改吗(建的人 / 负责人 / 共享为 write / 子任务随父) */ + public static function canWrite(array $t, int $personId): bool + { + $id = (int)($t['id'] ?? 0); + if ($id <= 0) { + return false; + } + if ((int)($t['creator_id'] ?? 0) === $personId || (int)($t['assignee_id'] ?? 0) === $personId) { + return true; + } + if (self::sharePerm($id, $personId) === 'write') { + return true; + } + // 子任务的权限跟着父任务走:父任务能改,它的子任务也就能改 + $pid = (int)($t['parent_id'] ?? 0); + if ($pid > 0) { + $st = Database::get()->prepare('SELECT id, parent_id, creator_id, assignee_id FROM tasks WHERE id = ?'); + $st->execute([$pid]); + $p = $st->fetch(); + if ($p) { + return self::canWrite($p, $personId); + } + } + return false; + } + + /** 我对这条任务的共享权限:'' / 'read' / 'write' */ + private static function sharePerm(int $taskId, int $personId): string + { + $st = Database::get()->prepare('SELECT perm FROM task_shares WHERE task_id = ? AND person_id = ?'); + $st->execute([$taskId, $personId]); + $v = $st->fetchColumn(); + return $v === false ? '' : (string)$v; + } + + // ---------------- 写 ---------------- + + /** @return int 新任务 id */ + public static function create(int $familyId, ?int $creatorId, array $d): int + { + $db = Database::get(); + $db->prepare( + 'INSERT INTO tasks (family_id, parent_id, title, note, status, due_at, position, + creator_id, assignee_id, repeat_rule) + VALUES (?,?,?,?,?,?,?,?,?,?)' + )->execute([ + $familyId, + self::parentId($familyId, (int)($d['parent_id'] ?? 0)), + self::title($d['title'] ?? ''), + self::textOrNull($d['note'] ?? ''), + self::status($d['status'] ?? ''), + self::dtOrNull($d['due_at'] ?? ''), + self::nextPosition($familyId, self::status($d['status'] ?? '')), + $creatorId ?: null, + (int)($d['assignee_id'] ?? 0) ?: null, + self::repeat($d['repeat_rule'] ?? ''), + ]); + return (int)$db->lastInsertId(); + } + + public static function update(int $familyId, int $id, array $d): void + { + $db = Database::get(); + $old = self::row($familyId, $id); + if (!$old) { + return; + } + // ⚠️ 父任务不能把自己挂到自己(或自己的子孙)下面 —— 会成环, + // 看板递归时会无限展开。直接忽略这种改动。 + $pid = self::parentId($familyId, (int)($d['parent_id'] ?? 0), $id); + $status = self::status($d['status'] ?? ''); + $db->prepare( + 'UPDATE tasks SET title=?, note=?, status=?, due_at=?, assignee_id=?, repeat_rule=?, + parent_id=? WHERE id=? AND family_id=?' + )->execute([ + self::title($d['title'] ?? ''), + self::textOrNull($d['note'] ?? ''), + $status, + self::dtOrNull($d['due_at'] ?? ''), + (int)($d['assignee_id'] ?? 0) ?: null, + self::repeat($d['repeat_rule'] ?? ''), + $pid, + $id, $familyId, + ]); + if ($status === 'done' && (string)$old['status'] !== 'done') { + self::markDone($familyId, $id); + } + } + + /** + * 改状态(看板拖动 / 勾复选框都走这里)。 + * + * ⚠️ 循环在这里发生:变成 done 且 repeat_rule 非空 → 生成下一条。 + * 只在**从非 done 变成 done** 时生成(重复提交 / 刷新不会刷出一堆)。 + * + * @return int 因循环而新生成的任务 id(没有则 0) + */ + public static function setStatus(int $familyId, int $id, string $status, int $position = 0): int + { + $old = self::row($familyId, $id); + if (!$old) { + return 0; + } + $status = self::status($status); + $db = Database::get(); + if ($position > 0) { + $db->prepare('UPDATE tasks SET status=?, position=? WHERE id=? AND family_id=?') + ->execute([$status, $position, $id, $familyId]); + } else { + $db->prepare('UPDATE tasks SET status=? WHERE id=? AND family_id=?') + ->execute([$status, $id, $familyId]); + } + if ($status === 'done' && (string)$old['status'] !== 'done') { + self::markDone($familyId, $id); + return self::spawnNext($familyId, $old); + } + return 0; + } + + /** + * 看板里搬一张卡:改状态 + 插到指定位置。 + * + * ⚠️ 位置是**整列重排**(10, 20, 30…)而不是"取相邻两个 position 的中点": + * 中点在两卡相邻时会挤不开(pos 相同 → 顺序由 id 兜底 → 拖了跟没拖一样), + * 而一列通常只有几十条,按序重排的代价可以忽略,换来的是永远不会排错。 + * + * @param int $beforeId 放在这张卡**前面**(0 = 放到末尾) + */ + public static function moveTo(int $familyId, int $id, string $status, int $beforeId = 0): void + { + $old = self::row($familyId, $id); + if (!$old) { + return; + } + $status = self::status($status); + $db = Database::get(); + $st = $db->prepare( + 'SELECT id FROM tasks WHERE family_id = ? AND status = ? AND parent_id IS NULL AND id <> ? + ORDER BY position, id' + ); + $st->execute([$familyId, $status, $id]); + $ids = array_map('intval', array_column($st->fetchAll(), 'id')); + + $idx = count($ids); + if ($beforeId > 0) { + $k = array_search($beforeId, $ids, true); + if ($k !== false) { + $idx = (int)$k; + } + } + array_splice($ids, $idx, 0, [$id]); + $up = $db->prepare('UPDATE tasks SET status = ?, position = ? WHERE id = ? AND family_id = ?'); + foreach ($ids as $i => $tid) { + $up->execute([$status, ($i + 1) * 10, $tid, $familyId]); + } + if ($status === 'done' && (string)$old['status'] !== 'done') { + self::markDone($familyId, $id); + self::spawnNext($familyId, $old); + } + } + + public static function destroy(int $familyId, int $id): void + { + // 子任务随父任务级联(外键 ON DELETE CASCADE 已保证), + // 但**附件与评论里的附件**没有外键,得自己清。 + $st = Database::get()->prepare('SELECT id FROM tasks WHERE family_id = ? AND (id = ? OR parent_id = ?)'); + $st->execute([$familyId, $id, $id]); + $ids = array_map('intval', array_column($st->fetchAll(), 'id')); + foreach ($ids as $tid) { + \App\Attachment::removeAll(self::ATTACH, $tid); + $cs = Database::get()->prepare('SELECT id FROM task_comments WHERE task_id = ?'); + $cs->execute([$tid]); + foreach ($cs->fetchAll() as $c) { + \App\Attachment::removeAll('task_comment', (int)$c['id']); + } + } + Database::get()->prepare('DELETE FROM tasks WHERE id = ? AND family_id = ?')->execute([$id, $familyId]); + } + + // ---------------- 评论 ---------------- + + public static function comments(int $taskId, int $viewerId = 0): array + { + $st = Database::get()->prepare( + 'SELECT m.*, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname + FROM task_comments m JOIN persons p ON p.id = m.person_id + LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? + WHERE m.task_id = ? ORDER BY m.created_at, m.id' + ); + $st->execute([$viewerId, $taskId]); + return $st->fetchAll(); + } + + public static function addComment(int $taskId, int $personId, string $content): int + { + $content = trim($content); + if ($content === '') { + return 0; + } + $db = Database::get(); + $db->prepare('INSERT INTO task_comments (task_id, person_id, content) VALUES (?,?,?)') + ->execute([$taskId, $personId, mb_substr($content, 0, 4000, 'UTF-8')]); + return (int)$db->lastInsertId(); + } + + public static function deleteComment(int $id, int $personId): void + { + // 只能删自己的(管理员也不删别人的留言 —— 家庭场景下这是聊天记录) + Database::get()->prepare('DELETE FROM task_comments WHERE id = ? AND person_id = ?') + ->execute([$id, $personId]); + } + + /** + * 把评论里的 @某人 高亮出来。 + * + * ⚠️ 先转义、再替换:**绝不能**反过来(先塞 再转义会把标签变成 <b>, + * 页面上一堆字面的尖括号)。匹配按"长的名字优先", + * 否则「@张三丰」里的「@张三」会先被短名字命中,剩下「丰」没被高亮。 + * + * @param array $names 可匹配的名字(含昵称),按长度降序排好 + */ + public static function commentHtml(string $content, array $names): string + { + $html = e($content); + $pairs = []; + foreach ($names as $nm) { + $nm = trim((string)$nm); + if ($nm !== '') { + $pairs[$nm] = '@' . e($nm) . ''; + } + } + uksort($pairs, static fn($a, $b) => mb_strlen($b, 'UTF-8') <=> mb_strlen($a, 'UTF-8')); + foreach ($pairs as $nm => $rep) { + $html = str_replace('@' . e($nm), $rep, $html); + } + return nl2br($html); + } + + // ---------------- 共享 ---------------- + + public static function shares(int $taskId): array + { + $st = Database::get()->prepare( + 'SELECT s.person_id, s.perm, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname + FROM task_shares s JOIN persons p ON p.id = s.person_id + LEFT JOIN nicknames n ON n.target_id = p.id + WHERE s.task_id = ? ORDER BY p.id' + ); + $st->execute([$taskId]); + return $st->fetchAll(); + } + + public static function setShare(int $taskId, int $personId, string $perm): void + { + if ($personId <= 0) { + return; + } + Database::get()->prepare( + 'INSERT INTO task_shares (task_id, person_id, perm) VALUES (?,?,?) + ON DUPLICATE KEY UPDATE perm = VALUES(perm)' + )->execute([$taskId, $personId, $perm === 'read' ? 'read' : 'write']); + } + + public static function dropShare(int $taskId, int $personId): void + { + Database::get()->prepare('DELETE FROM task_shares WHERE task_id = ? AND person_id = ?') + ->execute([$taskId, $personId]); + } + + // ---------------- 展示 ---------------- + + public static function statusWord(string $k): string + { + return self::STATUS[$k] ?? '待办'; + } + + public static function repeatWord(?string $k): string + { + $k = (string)$k; + return self::REPEAT[$k] ?? '不循环'; + } + + /** 截止日期的人话(今天/明天/逾期…),任务卡片与日历共用 */ + public static function dueWord(?string $due): string + { + if ($due === null || $due === '') { + return ''; + } + $ts = strtotime($due); + if ($ts === false) { + return ''; + } + $hasTime = substr((string)$due, 11, 5) !== '00:00'; + $d = date('Y-m-d', $ts); + $today = date('Y-m-d'); + if ($d === $today) { + return ($hasTime ? '今天 ' . substr((string)$due, 11, 5) : '今天') . ' 截止'; + } + if ($d === date('Y-m-d', strtotime('+1 day'))) { + return '明天截止'; + } + if ($d < $today) { + return '已逾期(' . date('n月j日', $ts) . ')'; + } + return date('n月j日', $ts) . ($hasTime ? ' ' . substr((string)$due, 11, 5) : '') . ' 截止'; + } + + public static function isOverdue(?string $due): bool + { + if ($due === null || $due === '') { + return false; + } + return substr((string)$due, 0, 10) < date('Y-m-d'); + } + + // ---------------- 内部 ---------------- + + private static function row(int $familyId, int $id): ?array + { + $st = Database::get()->prepare('SELECT * FROM tasks WHERE id = ? AND family_id = ?'); + $st->execute([$id, $familyId]); + $r = $st->fetch(); + return $r ?: null; + } + + /** 标完成:记 done_at */ + private static function markDone(int $familyId, int $id): void + { + Database::get()->prepare( + "UPDATE tasks SET done_at = NOW() WHERE id = ? AND family_id = ? AND done_at IS NULL" + )->execute([$id, $familyId]); + } + + /** + * 循环:生成下一条。**只在有截止日期时**生成 —— + * 没有基准日期的循环任务算不出"下一次是什么时候",硬造一条只会变成一条空的待办。 + * + * @return int 新任务 id(没生成则 0) + */ + private static function spawnNext(int $familyId, array $old): int + { + $rule = (string)($old['repeat_rule'] ?? ''); + $due = (string)($old['due_at'] ?? ''); + if ($rule === '' || $due === '') { + return 0; + } + $next = self::advance($due, $rule); + if ($next === '') { + return 0; + } + $db = Database::get(); + $db->prepare( + 'INSERT INTO tasks (family_id, parent_id, title, note, status, due_at, position, + creator_id, assignee_id, repeat_rule) + VALUES (?,?,?,?,?,?,?,?,?,?)' + )->execute([ + $familyId, + $old['parent_id'] !== null ? (int)$old['parent_id'] : null, + (string)$old['title'], + $old['note'], + 'todo', + $next, + $old['position'], + $old['creator_id'], + $old['assignee_id'], + $rule, + ]); + return (int)$db->lastInsertId(); + } + + /** 按循环规则把日期往后推一个周期(以**原截止日**为基准,不是以今天) */ + private static function advance(string $due, string $rule): string + { + $ts = strtotime($due); + if ($ts === false) { + return ''; + } + $map = ['day' => '+1 day', 'week' => '+1 week', 'month' => '+1 month', 'year' => '+1 year']; + if (!isset($map[$rule])) { + return ''; + } + // 推到"不早于今天"为止:一个每周任务拖了三周才勾,应该出现下周三, + // 而不是连着补三条过期的一周前。 + $t = $ts; + for ($i = 0; $i < 400; $i++) { + $t = strtotime($map[$rule], $t); + if ($t === false) { + return ''; + } + if (date('Y-m-d', $t) >= date('Y-m-d')) { + break; + } + } + return date('Y-m-d H:i:s', $t); + } + + /** + * 父任务 id 的合法性:存在、同家庭、不是自己、也不是自己的子孙(否则成环)。 + * + * @param int $selfId 被编辑的那条自己(0=新建,不存在成环问题) + */ + private static function parentId(int $familyId, int $pid, int $selfId = 0): ?int + { + if ($pid <= 0) { + return null; + } + if ($selfId > 0 && $pid === $selfId) { + return null; + } + $db = Database::get(); + $st = $db->prepare('SELECT parent_id FROM tasks WHERE id = ? AND family_id = ?'); + $st->execute([$pid, $familyId]); + $r = $st->fetch(); + if (!$r) { + return null; // 不存在 / 别家的 + } + // 从候选父任务往上走,撞到自己就说明"想把自己挂到自己的后代下面" + $cur = (int)($r['parent_id'] ?? 0); + for ($guard = 0; $guard < 10 && $cur > 0; $guard++) { + if ($selfId > 0 && $cur === $selfId) { + return null; + } + $up = $db->prepare('SELECT parent_id FROM tasks WHERE id = ?'); + $up->execute([$cur]); + $u = $up->fetch(); + if (!$u) { + break; + } + $cur = (int)($u['parent_id'] ?? 0); + } + return $pid; + } + + private static function nextPosition(int $familyId, string $status): int + { + $st = Database::get()->prepare( + 'SELECT COALESCE(MAX(position),0)+10 FROM tasks WHERE family_id = ? AND status = ? AND parent_id IS NULL' + ); + $st->execute([$familyId, $status]); + return (int)$st->fetchColumn(); + } + + private static function title(string $v): string + { + return mb_substr(trim($v), 0, 200, 'UTF-8'); + } + + private static function status(string $v): string + { + return array_key_exists($v, self::STATUS) ? $v : 'todo'; + } + + private static function repeat(string $v): ?string + { + return array_key_exists($v, self::REPEAT) && $v !== '' ? $v : null; + } + + private static function dtOrNull(string $v): ?string + { + $v = trim($v); + if ($v === '') { + return null; + } + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) { + $v .= ' 00:00:00'; + } + if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $v)) { + $v .= ':00'; + } + return preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $v) ? $v : null; + } + + private static function textOrNull(string $v): ?string + { + $v = trim($v); + return $v !== '' ? $v : null; + } +} diff --git a/app/Trip.php b/app/Trip.php new file mode 100644 index 0000000..0ab63d5 --- /dev/null +++ b/app/Trip.php @@ -0,0 +1,405 @@ + ['游玩', '🎡'], + 'meal' => ['用餐', '🍜'], + 'move' => ['赶路', '🚗'], + 'stay' => ['住宿', '🏨'], + 'other' => ['其他', '📌'], + ]; + + public const NODE_STATUS = [ + 'planned' => '计划中', + 'ongoing' => '进行中', + 'done' => '已完成', + 'cancelled' => '已取消', + ]; + + /** 全部旅程(按出发时间倒序) */ + public static function all(int $familyId, string $keyword = ''): array + { + $sql = 'SELECT e.id, e.title, e.starts_at, e.ends_at, e.location, e.note, + d.cover_key, d.summary, + (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS attendee_count, + (SELECT COUNT(*) FROM trip_nodes n WHERE n.event_id = e.id) AS node_count + FROM events e + LEFT JOIN trip_details d ON d.event_id = e.id + WHERE e.family_id = ? AND e.kind = ?'; + $bind = [$familyId, 'trip']; + $kw = trim($keyword); + if ($kw !== '') { + $sql .= ' AND (e.title LIKE ? OR e.location LIKE ? OR e.note LIKE ?)'; + $like = '%' . $kw . '%'; + array_push($bind, $like, $like, $like); + } + $sql .= ' ORDER BY e.starts_at DESC, e.id DESC'; + $st = Database::get()->prepare($sql); + $st->execute($bind); + return $st->fetchAll(); + } + + /** + * 给一批旅程补上「同行的人」。 + * + * ⚠️ 必须一次性补(N+1 会让列表在几十趟旅程时明显变慢,观影就是这么踩的)。 + * 一次查询拿全部 event_attendees,再按 event_id 分桶挂回去。 + */ + public static function attachAttendees(array &$rows, int $viewerId = 0): void + { + $ids = []; + foreach ($rows as $r) { + $id = (int)($r['id'] ?? 0); + if ($id > 0) { + $ids[] = $id; + } + } + $byEvent = []; + foreach ($rows as $r) { + $byEvent[(int)$r['id']] = []; + } + if ($ids) { + $in = implode(',', array_fill(0, count($ids), '?')); + $st = Database::get()->prepare( + 'SELECT a.event_id, p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname + FROM event_attendees a JOIN persons p ON p.id = a.person_id + LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? + WHERE a.event_id IN (' . $in . ') + ORDER BY a.position, p.id' + ); + $st->execute(array_merge([$viewerId], $ids)); + foreach ($st->fetchAll() as $a) { + $byEvent[(int)$a['event_id']][] = $a; + } + } + foreach ($rows as &$r) { + $r['attendees'] = $byEvent[(int)$r['id']] ?? []; + } + unset($r); + } + + /** 一条完整旅程 */ + public static function find(int $familyId, int $id, int $viewerId = 0): ?array + { + $st = Database::get()->prepare( + "SELECT e.*, d.cover_key, d.summary + FROM events e LEFT JOIN trip_details d ON d.event_id = e.id + WHERE e.family_id = ? AND e.id = ? AND e.kind = 'trip'" + ); + $st->execute([$familyId, $id]); + $row = $st->fetch(); + if (!$row) { + return null; + } + $row['attendees'] = self::attendees($id, $viewerId); + $row['photos'] = \App\Attachment::list('trip', $id); + return $row; + } + + /** 同行的人(带「我叫他什么」与主色,页面按人画标签) */ + public static function attendees(int $eventId, int $viewerId = 0): array + { + $st = Database::get()->prepare( + 'SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname + FROM event_attendees a JOIN persons p ON p.id = a.person_id + LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? + WHERE a.event_id = ? + ORDER BY a.position, p.id' + ); + $st->execute([$viewerId, $eventId]); + return $st->fetchAll(); + } + + /** @return int 事件 id */ + public static function create(int $familyId, ?int $creatorId, array $d): int + { + $db = Database::get(); + $db->prepare( + "INSERT INTO events (family_id, creator_id, kind, title, starts_at, ends_at, location, note, source_ref) + VALUES (?, ?, 'trip', ?, ?, ?, ?, ?, ?)" + )->execute([ + $familyId, $creatorId, (string)$d['title'], + self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''), + self::textOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''), + $d['source_ref'] ?? null, + ]); + $id = (int)$db->lastInsertId(); + self::saveDetails($id, $d); + self::saveAttendees($id, $d['attendees'] ?? []); + return $id; + } + + public static function update(int $familyId, int $id, array $d): void + { + $db = Database::get(); + $db->prepare( + "UPDATE events SET title=?, starts_at=?, ends_at=?, location=?, note=? + WHERE id=? AND family_id=? AND kind='trip'" + )->execute([ + (string)$d['title'], + self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''), + self::textOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''), + $id, $familyId, + ]); + self::saveDetails($id, $d); + self::saveAttendees($id, $d['attendees'] ?? []); + } + + private static function saveDetails(int $eventId, array $d): void + { + $db = Database::get(); + $row = [ + self::keyOrNull($d['cover_key'] ?? ''), + self::textOrNull($d['summary'] ?? ''), + ]; + $has = $db->prepare('SELECT COUNT(*) FROM trip_details WHERE event_id = ?'); + $has->execute([$eventId]); + if ((int)$has->fetchColumn() > 0) { + $db->prepare('UPDATE trip_details SET cover_key=?, summary=? WHERE event_id=?') + ->execute([...$row, $eventId]); + } else { + $db->prepare('INSERT INTO trip_details (cover_key, summary, event_id) VALUES (?,?,?)') + ->execute([...$row, $eventId]); + } + } + + public static function saveAttendees(int $eventId, array $personIds): void + { + $db = Database::get(); + $db->prepare('DELETE FROM event_attendees WHERE event_id = ?')->execute([$eventId]); + $ins = $db->prepare('INSERT IGNORE INTO event_attendees (event_id, person_id, position) VALUES (?,?,?)'); + $pos = 0; + foreach ($personIds as $pid) { + $pid = (int)$pid; + if ($pid > 0) { + $pos += 10; + $ins->execute([$eventId, $pid, $pos]); + } + } + } + + public static function destroy(int $familyId, int $id): void + { + // 照片与节点附件一并清(events 级联只带走数据库行,存储对象要自己删) + foreach (\App\Attachment::list('trip', $id) as $a) { + \App\Attachment::remove((int)$a['id']); + } + foreach (self::nodes($id) as $n) { + foreach (\App\Attachment::list('trip_node', (int)$n['id']) as $a) { + \App\Attachment::remove((int)$a['id']); + } + } + Database::get()->prepare("DELETE FROM events WHERE id=? AND family_id=? AND kind='trip'") + ->execute([$id, $familyId]); + } + + // ---------------- 行程节点 ---------------- + + /** 这趟旅程的全部节点(plan / reality 两轨,按 position 排) */ + public static function nodes(int $eventId, int $viewerId = 0): array + { + $st = Database::get()->prepare( + 'SELECT * FROM trip_nodes WHERE event_id = ? ORDER BY category, position, starts_at, id' + ); + $st->execute([$eventId]); + $rows = $st->fetchAll(); + $ids = array_map('intval', array_column($rows, 'id')); + // 一次性把全部节点的附件取出来(逐节点查会让"十几段的旅程"变成几十次查询) + $attach = []; + foreach ($ids as $nid) { + $attach[$nid] = []; + } + if ($ids) { + $in = implode(',', array_fill(0, count($ids), '?')); + $sa = Database::get()->prepare( + "SELECT * FROM attachments WHERE target_type = 'trip_node' AND target_id IN ($in) + ORDER BY position, id" + ); + $sa->execute($ids); + foreach ($sa->fetchAll() as $a) { + $attach[(int)$a['target_id']][] = $a; + } + } + foreach ($rows as &$n) { + $n['members'] = self::nodeMembersFor((int)$n['id'], $viewerId); + $n['attachs'] = $attach[(int)$n['id']] ?? []; + } + unset($n); + return $rows; + } + + public static function node(int $id): ?array + { + $st = Database::get()->prepare('SELECT * FROM trip_nodes WHERE id = ?'); + $st->execute([$id]); + $r = $st->fetch(); + return $r ?: null; + } + + public static function saveNode(int $familyId, int $eventId, array $d, ?int $id = null): int + { + $db = Database::get(); + $fields = [ + self::textOrNull2($d['title'] ?? ''), + self::textOrNull($d['detail'] ?? ''), + self::textOrNull($d['place'] ?? ''), + self::dtOrNull($d['starts_at'] ?? ''), + self::dtOrNull($d['ends_at'] ?? ''), + in_array($d['node_type'] ?? '', array_keys(self::NODE_TYPES), true) ? $d['node_type'] : 'other', + in_array($d['status'] ?? '', array_keys(self::NODE_STATUS), true) ? $d['status'] : 'planned', + ($d['category'] ?? '') === 'reality' ? 'reality' : 'plan', + (int)($d['position'] ?? 100), + ]; + if ($id !== null && $id > 0) { + $db->prepare( + 'UPDATE trip_nodes SET title=?, detail=?, place=?, starts_at=?, ends_at=?, + node_type=?, status=?, category=?, position=? WHERE id=?' + )->execute([...$fields, $id]); + $nid = $id; + } else { + $db->prepare( + 'INSERT INTO trip_nodes (family_id, event_id, title, detail, place, starts_at, ends_at, + node_type, status, category, position) VALUES (?,?,?,?,?,?,?,?,?,?,?)' + )->execute([$familyId, $eventId, ...$fields]); + $nid = (int)$db->lastInsertId(); + } + self::saveNodeMembers($nid, $d['members'] ?? []); + return $nid; + } + + public static function deleteNode(int $id): void + { + foreach (\App\Attachment::list('trip_node', $id) as $a) { + \App\Attachment::remove((int)$a['id']); + } + Database::get()->prepare('DELETE FROM trip_nodes WHERE id = ?')->execute([$id]); + } + + /** + * 这一段里有谁。 + * + * ⚠️ 昵称必须按**看页面的那个人**的视角取,所以要显式传 $viewerId —— + * 成员表里只有"谁参加了",没有"谁在看"。 + */ + public static function nodeMembersFor(int $nodeId, int $viewerId): array + { + $st = Database::get()->prepare( + 'SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname + FROM trip_node_members m JOIN persons p ON p.id = m.person_id + LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? + WHERE m.node_id = ? ORDER BY p.id' + ); + $st->execute([$viewerId, $nodeId]); + return $st->fetchAll(); + } + + public static function saveNodeMembers(int $nodeId, array $personIds): void + { + $db = Database::get(); + $db->prepare('DELETE FROM trip_node_members WHERE node_id = ?')->execute([$nodeId]); + $ins = $db->prepare('INSERT IGNORE INTO trip_node_members (node_id, person_id) VALUES (?,?)'); + foreach ($personIds as $pid) { + $pid = (int)$pid; + if ($pid > 0) { + $ins->execute([$nodeId, $pid]); + } + } + } + + // ---------------- 展示 ---------------- + + /** 起止日期的人话展示(与 Movie::formatWatch 同款口径) */ + public static function formatSpan(?string $start, ?string $end): string + { + if ($start === null || $start === '') { + return '日期待补'; + } + $ts = strtotime($start); + if ($ts === false) { + return '日期待补'; + } + $s = date('Y年n月j日', $ts); + if ($end === null || $end === '') { + return $s; + } + $te = strtotime($end); + if ($te === false) { + return $s; + } + return $s . ' – ' . date('Y年n月j日', $te); + } + + public static function typeWord(string $key): string + { + return self::NODE_TYPES[$key][0] ?? '其他'; + } + + public static function typeIcon(string $key): string + { + return self::NODE_TYPES[$key][1] ?? '📌'; + } + + public static function statusWord(string $key): string + { + return self::NODE_STATUS[$key] ?? '计划中'; + } + + // ---------------- 小工具 ---------------- + + private static function dtOrNull(string $v): ?string + { + $v = trim($v); + if ($v === '') { + return null; + } + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) { + $v .= ' 00:00:00'; + } + return preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $v) ? $v : null; + } + + private static function textOrNull(string $v): ?string + { + $v = trim($v); + return $v !== '' ? $v : null; + } + + private static function textOrNull2(string $v): string + { + return mb_substr(trim($v), 0, 200, 'UTF-8'); + } + + private static function keyOrNull(string $v): ?string + { + $v = ltrim(trim($v), '/'); + if ($v === '' || strpos($v, '..') !== false || strpos($v, "\0") !== false) { + return null; + } + return $v; + } +} diff --git a/app/Upload.php b/app/Upload.php index 0b3029c..66da98a 100644 --- a/app/Upload.php +++ b/app/Upload.php @@ -37,7 +37,9 @@ final class Upload * * @param string $slotKey ImageSlot::SLOTS 里的键(决定比例与输出尺寸) * @param string $cropRaw 前端挑的裁剪区域,归一化 "x,y,w,h"(相对原图 0..1);留空=居中裁 - * @return array{ok:bool,file?:string,error?:string,note?:string,slot?:string,crop?:array} + * @return array{ok:bool,file?:string,error?:string,note?:string,slot?:string,crop?:array,color?:string} + * color = 从成品图算出的主色 '#rrggbb'(算不出是 '')。 + * **就在这时算**:图已经在内存里了,零额外 IO;等渲染时再算就得回读存储。 */ public static function image(int $familyId, array $file, string $slotKey, string $cropRaw = ''): array { @@ -137,6 +139,10 @@ final class Upload imagecopyresampled($canvas, $src, 0, 0, $sx, $sy, $outW, $outH, $sw, $sh); imagedestroy($src); + // 主色:**必须在销毁画布之前**取(算的就是这张成品图,不是原图 —— + // 用户裁掉了半张脸之后,剩下的那半张才算得准)。 + $color = AvatarColor::fromGd($canvas); + // 先在临时目录生成,再整体交给 Storage —— 这样本地与 OSS 是同一条路径 $tmpOut = tempnam(sys_get_temp_dir(), 'fbimg'); if ($tmpOut === false || !imagejpeg($canvas, $tmpOut, 88)) { @@ -153,7 +159,7 @@ final class Upload if (empty($res['ok'])) { return ['ok' => false, 'error' => '保存失败:' . (string)($res['error'] ?? '')]; } - $out = ['ok' => true, 'file' => $key, 'slot' => $slotKey, + $out = ['ok' => true, 'file' => $key, 'slot' => $slotKey, 'color' => $color, 'crop' => ['x' => $sx, 'y' => $sy, 'w' => $sw, 'h' => $sh]]; if (!empty($res['note'])) { $out['note'] = (string)$res['note']; @@ -163,7 +169,7 @@ final class Upload /** * 头像(= avatar 位置)。保留这个入口,调用方不必关心槽位名。 - * @return array{ok:bool,file?:string,error?:string,note?:string} + * @return array{ok:bool,file?:string,error?:string,note?:string,color?:string} */ public static function avatar(int $familyId, array $file, string $cropRaw = ''): array { diff --git a/app/helpers.php b/app/helpers.php index 42dc049..2de5487 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -62,6 +62,78 @@ function media_url(?string $key, array $extra = []): string return 'media.php?' . http_build_query(array_merge(['k' => ltrim($key, '/')], $extra)); } +// ---------------- 人:怎么称呼、什么颜色 ---------------- + +/** + * 页面上「这个人叫什么」的**唯一出口**。 + * + * 用户 2026-10-01 第 3 条:凡是出现人员的地方,默认显示「我」对他的昵称/称呼, + * 不要直接冒出姓名。所以这里先看 nicknames 里的 my_nickname,没有才退回姓名。 + * + * @param array $p 至少要有 name;有 my_nickname 就优先用;`shown` 是某些查询 + * 已经算好的显示名(如观影表单的已选人),也认。 + */ +function person_name(array $p): string +{ + $nick = trim((string)($p['my_nickname'] ?? '')); + if ($nick !== '') { + return $nick; + } + $shown = trim((string)($p['shown'] ?? '')); + if ($shown !== '') { + return $shown; + } + return (string)($p['name'] ?? ''); +} + +/** + * 这个人的底色(浅色底)与字色(深色字)—— 都由他自己的主色推出来。 + * + * 主色来自头像(上传时算好存 persons.avatar_color),没头像或头像是灰片时 + * 由姓名稳定地推一个。页面上一律走这两个函数,不要再自己写 '#eee'。 + */ +function person_bg(array $p): string +{ + return \App\AvatarColor::bg(\App\AvatarColor::for($p)); +} + +function person_fg(array $p): string +{ + return \App\AvatarColor::fg(\App\AvatarColor::for($p)); +} + +function person_bd(array $p): string +{ + return \App\AvatarColor::bd(\App\AvatarColor::for($p)); +} + +/** + * 一个人的彩色标签(头像 + 称呼 + 他自己的底色)—— 全站唯一画法。 + * + * ⚠️ `$href` 为空时输出 ``:瀑布流里整张卡片已经是 `` 了, + * `` 套 `` 是非法 HTML,浏览器会把外层链接切开(卡片点不动)。 + * 所以列表/瀑布一律传空,只有详情页那种独立标签才传链接。 + */ +function person_chip(array $p, string $href = '', string $extraClass = ''): string +{ + $name = person_name($p); + $style = 'background:' . person_bg($p) . ';color:' . person_fg($p) + . ';border-color:' . person_bd($p) . ';'; + if (!empty($p['avatar'])) { + $av = ''; + } else { + $av = '' + . e(mb_substr($name, 0, 1, 'UTF-8')) . ''; + } + $cls = 'viewer-tag' . ($extraClass !== '' ? ' ' . $extraClass : ''); + $inner = $av . '' . e($name) . ''; + if ($href !== '') { + return '' + . $inner . ''; + } + return '' . $inner . ''; +} + /** * 读「日期 + 24 小时制时:分:秒」组合控件(templates/_datetime.php 产出)。 * diff --git a/assets/css/app.css b/assets/css/app.css index 8690424..270c3b1 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -183,6 +183,13 @@ a:hover { text-decoration: underline; } box-shadow: var(--shadow); } .card-pad { padding: 20px; } +/* 相邻的两块之间一定要有缝 —— 影院管理页「添加影院」与「影院列表」、 + 年度报告的几张卡,曾经紧贴成一个整块,看不出是两个东西。 + 统一在这里给间距,而不是每个页面各写一次(写一次就会漏一处)。 */ +.card + .card { margin-top: 18px; } +/* ⚠️ 上面**只写 .card + .card**:.section 自己有 margin-top:28px, + 若在这里再写 `.card + .section { margin-top:18px }`,特异性 0,2,0 会压过 + `.section` 的 0,1,0,反而把原本 28px 的间距改小 —— 修 A 不能顺手弄坏 B。 */ /* 首页头图 */ .hero { @@ -439,6 +446,11 @@ textarea.input { min-height: 76px; resize: vertical; } ⚠️ 卡片/连线都不是我们生成的:卡片是库建的真实
(.card_cont > .card > 我们的 .kin-card),连线是 svg 里的 。改版式只能从这两个钩子下手。 */ +/* v14:树根换成「最高祖先」之后整棵树会很大,光靠拖很累 —— + 这两个按钮把「看全部」与「回到我」分开。放在画布**外面**, + 免得被 svg 的拖拽吞掉点击(画布整块都被库接管了手势)。 */ +.kin-tools { display: flex; gap: 8px; justify-content: flex-end; margin: 0 0 10px; } + .kin-chart { position: relative; width: 100%; @@ -999,9 +1011,19 @@ textarea.input { min-height: 76px; resize: vertical; } display: inline-flex; align-items: center; gap: 4px; font-size: 12px; padding: 2px 8px; border-radius: 12px; background: var(--line-2); color: var(--ink); text-decoration: none; + /* 底色/字色/边线由 person_chip() 写成行内样式(每人一个颜色,来自他的头像主色); + 这里只给**没有行内样式时的兜底**(旧的灰底标签):1px 透明边, + 这样有边线和没边线的两种标签高度完全一致,不会一行高一行矮。 */ + border: 1px solid transparent; } -.viewer-tag:hover { text-decoration: none; background: var(--line); } -.viewer-tag img { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; } +.viewer-tag:hover { text-decoration: none; filter: brightness(.96); } +.viewer-tag .chip-av { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; } +.viewer-tag .chip-av-text { + background: var(--faint); color: #fff; + display: inline-flex; align-items: center; justify-content: center; + font-size: 10px; line-height: 1; +} +.viewer-tag .chip-name { white-space: nowrap; } /* ---- 详情页 ---- */ .detail-hero { @@ -1042,8 +1064,8 @@ textarea.input { min-height: 76px; resize: vertical; } .empty-hint { color: var(--muted); font-size: 14px; margin: 0; } .viewers-list { display: flex; flex-wrap: wrap; gap: 12px; } .viewers-list .viewer-tag { padding: 4px 12px 4px 6px; font-size: 14px; } -.viewers-list .viewer-tag img, -.viewers-list .rv-avatar-text { width: 28px; height: 28px; } +.viewers-list .viewer-tag .chip-av { width: 28px; height: 28px; } +.viewers-list .viewer-tag .chip-av-text { font-size: 13px; } .cinema-info { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; font-size: 15px; } @@ -1464,3 +1486,264 @@ textarea.input { min-height: 76px; resize: vertical; } font-size: 14px; padding: 4px 12px; border-radius: 999px; background: var(--line-2); color: var(--brand-ink); border: 1px solid var(--line); } + +/* ==================== v12:旅程 / 日程 / 资源 / 任务 ==================== */ +/* 四个新模块共用一套"卡片骨架"(.card + .card-pad + .cinema-h2 的小标题), + 下面只写各模块**特有**的部件,避免又造一套并行的卡片。 */ + +/* ---- 旅程 ---- */ +.trip-list { display: flex; flex-direction: column; gap: 12px; } +.trip-card { + display: flex; gap: 16px; background: var(--surface); border: 1px solid var(--line); + border-radius: var(--radius); padding: 14px; text-decoration: none; color: inherit; + transition: transform .18s ease, box-shadow .18s ease; +} +.trip-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-l); text-decoration: none; } +.trip-cover { + width: 132px; height: 96px; flex: 0 0 auto; border-radius: var(--radius-s); + overflow: hidden; background: #F1E7D6; display: flex; align-items: center; justify-content: center; +} +.trip-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.trip-cover-empty { font-size: 34px; opacity: .55; } +.trip-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 6px; } +.trip-title { font-size: 17px; font-weight: 600; margin: 0; } +.trip-meta { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; color: var(--muted); } +.trip-sum { margin: 0; font-size: 14px; color: var(--muted); line-height: 1.6; } +.trip-people { display: flex; flex-wrap: wrap; gap: 4px 8px; } + +.trip-hero { display: flex; gap: 20px; flex-wrap: wrap; } +.trip-hero-cover { + width: 260px; max-width: 100%; aspect-ratio: 4 / 3; flex: 0 0 auto; border-radius: var(--radius-s); + overflow: hidden; background: #F1E7D6; display: flex; align-items: center; justify-content: center; +} +.trip-hero-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.trip-hero-body { flex: 1 1 320px; min-width: 0; display: flex; flex-direction: column; gap: 10px; } +.trip-loc { margin: 0; font-size: 14px; color: var(--muted); } +.cover-now { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } +.cover-now img { width: 96px; height: 64px; object-fit: cover; border-radius: 8px; display: block; } + +.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; } +.photo-cell { margin: 0; background: #F1E7D6; border-radius: var(--radius-s); overflow: hidden; } +.photo-cell a { display: block; } +.photo-cell img { width: 100%; height: 120px; object-fit: cover; display: block; } +.photo-del { padding: 6px; text-align: center; } + +/* ---- 行程节点 ---- */ +.node-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } +.node-row { + display: flex; gap: 12px; align-items: flex-start; padding: 12px 14px; + border: 1px solid var(--line-2); border-radius: var(--radius-s); background: #FDFBF7; +} +.node-row.status-cancelled { opacity: .62; } +.node-row.status-done .node-head b { color: var(--muted); } +.node-ico { font-size: 20px; flex: 0 0 auto; line-height: 1.4; } +.node-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 5px; } +.node-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.node-head b { font-size: 15px; } +.node-badge { + font-size: 11px; padding: 1px 8px; border-radius: 999px; + background: var(--line-2); color: var(--muted); white-space: nowrap; +} +.node-row.status-done .node-badge { background: #E6F0DC; color: #4A7226; } +.node-place, .node-time { font-size: 12px; color: var(--muted); } +.node-detail { margin: 0; font-size: 14px; line-height: 1.65; } +.node-ops { flex: 0 0 auto; display: flex; flex-direction: column; gap: 6px; align-items: flex-end; } +.node-edit > summary { cursor: pointer; } +.node-add { margin-top: 14px; border-top: 1px solid var(--line-2); padding-top: 12px; } +.node-add > summary { cursor: pointer; font-size: 14px; } +.node-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; } +.nf-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.nf-grid .field.full { grid-column: 1 / -1; } +.nf-members { display: flex; flex-wrap: wrap; gap: 6px 16px; } + +/* ---- 附件行(旅程节点 / 资源 / 任务共用) ---- */ +.attach-row { display: flex; flex-wrap: wrap; gap: 8px; } +.attach-item { + display: inline-flex; align-items: center; gap: 5px; font-size: 13px; + padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line); + background: var(--surface); color: var(--brand-ink); text-decoration: none; +} +.attach-item:hover { border-color: var(--brand); text-decoration: none; } + +/* ---- 日程(月视图,群晖 Calendar 的外观) ---- */ +.cal-wrap { display: grid; grid-template-columns: 230px 1fr; gap: 18px; align-items: start; } +.cal-side { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; } +.cal-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.cal-item { display: flex; flex-direction: column; gap: 4px; } +.cal-item > .node-badge { align-self: flex-start; } +.cal-toggle { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; } +.cal-toggle input { accent-color: var(--brand); } +.cal-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cal-note { font-size: 12px; color: var(--faint); line-height: 1.6; margin: 12px 0 0; } +.cal-main { min-width: 0; } +.cal-add { margin-bottom: 18px; } +.cal-grid-card { padding: 0; overflow: hidden; } +.cal-head { display: grid; grid-template-columns: repeat(7, 1fr); border-bottom: 1px solid var(--line); } +.cal-wd { padding: 8px 0; text-align: center; font-size: 12px; color: var(--muted); } +.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); } +.cal-cell { + min-height: 104px; padding: 6px 6px 8px; border-right: 1px solid var(--line-2); + border-bottom: 1px solid var(--line-2); display: flex; flex-direction: column; gap: 4px; +} +.cal-cell:nth-child(7n) { border-right: 0; } +.cal-cell:nth-last-child(-n+7) { border-bottom: 0; } +.cal-cell.is-out { background: #FCFAF6; } +.cal-cell.is-out .cal-dnum { color: var(--faint); } +.cal-cell.is-today { background: #FDF6F1; } +.cal-dnum { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; padding-left: 2px; } +.cal-cell.is-today .cal-dnum { + color: #fff; background: var(--brand); border-radius: 999px; + width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; +} +.cal-evs { display: flex; flex-direction: column; gap: 3px; } +.cal-ev { + display: block; font-size: 12px; line-height: 1.35; color: #fff; + padding: 2px 6px; border-radius: 5px; text-decoration: none; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.cal-ev:hover { text-decoration: none; filter: brightness(1.08); } +.cal-ev.is-allday { font-weight: 500; } +.cal-more { font-size: 11px; color: var(--faint); padding-left: 2px; } +.ev-del { margin-top: 16px; } +.cal-share-name { font-size: 13px; color: var(--muted); } + +@media (max-width: 860px) { + .cal-wrap { grid-template-columns: 1fr; } + .cal-cell { min-height: 78px; } +} + +/* ---- 资源 ---- */ +.res-list { display: flex; flex-direction: column; gap: 8px; } +.res-item { border: 1px solid var(--line-2); border-radius: var(--radius-s); background: #FDFBF7; } +.res-head { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + padding: 12px 14px; cursor: pointer; list-style: none; +} +.res-head::-webkit-details-marker { display: none; } +.res-head:hover { background: #FAF7F1; } +.res-title { font-size: 15px; font-weight: 600; } +.res-n { font-size: 12px; color: var(--faint); } +.res-body { padding: 0 14px 14px; display: flex; flex-direction: column; gap: 10px; } +.res-size { font-size: 11px; color: var(--faint); } +.res-inline { display: inline; margin: 0; } +.res-shares { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.res-label { font-size: 12px; color: var(--muted); } +.res-ops { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; } +.res-share-add > summary { cursor: pointer; } + +/* ---- 任务看板 ---- */ +.kanban { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; align-items: start; } +.kb-col { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; } +.kb-head { + display: flex; align-items: center; gap: 8px; padding: 12px 16px; + border-bottom: 1px solid var(--line-2); font-size: 14px; +} +.kb-head .kb-n { margin-left: auto; font-size: 12px; color: var(--faint); } +.kb-body { padding: 12px; display: flex; flex-direction: column; gap: 10px; min-height: 90px; } +.kb-body.is-over { background: #FDF6F1; outline: 2px dashed var(--brand-2); outline-offset: -4px; } +.kb-card { + border: 1px solid var(--line-2); border-radius: var(--radius-s); background: #FDFBF7; + padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; +} +.kb-card.is-dragging { opacity: .45; } +.kb-card.is-overdue { border-color: rgba(192, 83, 63, .4); } +.kb-title { font-size: 14px; font-weight: 500; color: var(--ink); } +.kb-title:hover { color: var(--brand); } +.kb-meta { display: flex; flex-wrap: wrap; gap: 4px 8px; font-size: 12px; color: var(--muted); } +.kb-due.is-late, .kb-late { color: var(--red); } +.kb-ico { white-space: nowrap; } +.kb-sub { display: flex; align-items: center; gap: 8px; } +.kb-bar { flex: 1 1 auto; height: 5px; border-radius: 999px; background: var(--line-2); overflow: hidden; } +.kb-bar i { display: block; height: 100%; background: var(--green); } +.kb-sub-n { font-size: 11px; color: var(--faint); font-variant-numeric: tabular-nums; } +.kb-foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.kb-move { margin-left: auto; display: flex; gap: 4px; } +.kb-empty { padding: 6px 2px; } +.hidden-form { display: none; } + +/* ---- 任务详情 ---- */ +.sub-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.sub-row { + display: flex; align-items: center; gap: 10px; padding: 7px 10px; + border: 1px solid var(--line-2); border-radius: var(--radius-s); background: #FDFBF7; +} +.sub-row.is-done .sub-title { color: var(--muted); text-decoration: line-through; } +.sub-check { + background: none; border: 0; cursor: pointer; font-size: 17px; line-height: 1; + padding: 0 2px; color: var(--brand); +} +.sub-check.is-static { cursor: default; color: var(--muted); } +.sub-title { flex: 1 1 auto; min-width: 0; font-size: 14px; } +.cmt-list { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 10px; } +.cmt-row { border-top: 1px solid var(--line-2); padding-top: 10px; } +.cmt-who { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.cmt-time { font-size: 11px; color: var(--faint); } +.cmt-body { font-size: 14px; line-height: 1.7; margin: 6px 0; } +.cmt-body .mention { color: var(--brand-ink); background: #F6E3D7; border-radius: 4px; padding: 0 3px; } +.cmt-form { margin-top: 6px; } +.task-danger { margin-top: 18px; display: flex; justify-content: flex-end; } + +@media (max-width: 860px) { + .kanban { grid-template-columns: 1fr; } + .nf-grid { grid-template-columns: 1fr; } +} + +/* ---- 个人设置里的「模块」两块 ---- */ +.mod-order .mod-row { cursor: grab; } +.mod-order .mod-row.is-dragged { opacity: .45; cursor: grabbing; } +.mod-order .mod-row.is-drop-before { box-shadow: inset 0 3px 0 var(--brand); } +.mod-order .mod-row.is-drop-after { box-shadow: inset 0 -3px 0 var(--brand); } +.mod-pick { display: flex; align-items: center; gap: 8px; flex: 1 1 auto; min-width: 0; cursor: pointer; } +.mod-pick input { accent-color: var(--brand); } +.mod-swatches { display: flex; flex-wrap: wrap; gap: 8px 18px; } +.mod-swatch { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); } +.mod-colors { display: flex; flex-direction: column; gap: 10px; } +.mod-color-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.mod-color-name { flex: 0 0 76px; font-size: 14px; } +.mod-preset { display: flex; flex-wrap: wrap; gap: 4px; } +.mod-preset .preset-color { cursor: pointer; } + +/* ---- 首页 dashboard ---- */ +.hero-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.dash-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } +.dash-row { + display: flex; align-items: baseline; gap: 10px; padding: 8px 2px; + border-top: 1px solid var(--line-2); flex-wrap: wrap; +} +.dash-row:first-child { border-top: 0; } +.dash-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; } +.dash-when { flex: 0 0 96px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; } +.dash-what { flex: 1 1 auto; min-width: 0; font-size: 14px; } +.dash-tag { flex: 0 0 auto; font-size: 11px; color: var(--faint); } +@media (max-width: 620px) { + .dash-when { flex: 0 0 auto; } +} + +/* ---- v14:导航条从 4 个模块变成最多 8 个,窄屏必须能横向滑动 ---- + ⚠️ 这是本轮**实测出来的回归**:模块从 4 个加到 8 个之后, + 375px 视口下 scrollWidth=472 > clientWidth=375,**整页横向溢出** + (顶栏把 body 撑宽了)。以前 4 个模块刚好塞得下,所以从没有人给 .nav 写过窄屏规则。 + 修法用「导航自己横向滚动」而不是「换行」: + 换行会把顶栏从 60px 撑成两行,sticky 顶栏一高一矮很难看,而且滚动时页面会跳。 + ⚠️ 断点必须是 **720px**(站点既有的"窄屏留白 16px"断点),不能用 860: + 746px 宽时若只改顶栏留白,就会出现「顶栏 x=16、正文 x=24」错位 8px + (几何检查会直接报出来)。窄屏规则只在站点真正切到小留白的那一档生效。 */ +@media (max-width: 720px) { + .topbar-inner { gap: 12px; padding: 0 16px; } + /* min-width:0 是关键:flex 子项默认 min-width:auto,会拒绝被压缩, + 于是内部再 overflow-x 也撑不出滚动条,而是把父级一起顶宽。 */ + .nav { + flex: 1 1 auto; + min-width: 0; + margin-left: 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; /* Firefox */ + } + .nav::-webkit-scrollbar { display: none; } /* Chrome/Safari:藏掉滚动条,仍可滑 */ + .nav a { flex: 0 0 auto; padding: 6px 10px; } + .topbar-right { margin-left: 0; gap: 6px; } + /* 窄屏把「家书」两个字收起来,只留图标 —— 名字占的宽度正好够多放两个模块 */ + .brand span { display: none; } + .who .who-name { display: none; } +} diff --git a/index.php b/index.php index e08411d..1cae5e7 100644 --- a/index.php +++ b/index.php @@ -13,6 +13,7 @@ require __DIR__ . '/app/bootstrap.php'; use App\Account; use App\Auth; use App\Birthday; +use App\Calendar; use App\Cinema; use App\Crypto; use App\Database; @@ -21,14 +22,18 @@ use App\Family; use App\Kinship; use App\Movie; use App\MovieReview; +use App\Module; use App\MovieStats; use App\Nickname; use App\Person; use App\PersonGroup; use App\PersonRoster; use App\Relations; +use App\Resource; use App\Schema; use App\Setting; +use App\Task; +use App\Trip; use App\Upload; use App\View; @@ -195,6 +200,166 @@ function render_me_settings(array $person, array $errors = []): void ], 'settings'); } +/** + * 上传并挂一组附件到某个宿主(旅程照片 / 资源附件 / 任务附件…全走这一处)。 + * + * 同时吃「单文件」与「多文件」两种表单字段:`` + * 与 ``(后者 PHP 会把每个子项变成数组)。 + * 逐个交给 App\Attachment::save —— 一张失败不该连累其它几张(照片多选尤其明显)。 + * + * @return string[] 失败原因(空数组 = 全部成功) + */ +function attach_save_files(int $familyId, string $type, int $targetId, string $field, int $uploaderId): array +{ + $files = []; + if (!empty($_FILES[$field]) && is_array($_FILES[$field])) { + $f = $_FILES[$field]; + if (isset($f['name']) && is_array($f['name'])) { + foreach ($f['name'] as $i => $_n) { + $files[] = [ + 'name' => $f['name'][$i], + 'type' => $f['type'][$i] ?? '', + 'tmp_name' => $f['tmp_name'][$i] ?? '', + 'error' => $f['error'][$i] ?? UPLOAD_ERR_NO_FILE, + 'size' => $f['size'][$i] ?? 0, + ]; + } + } elseif (isset($f['name'])) { + $files[] = $f; + } + } + $warns = []; + foreach ($files as $f) { + $res = \App\Attachment::save($familyId, $type, $targetId, $f, $uploaderId); + if (empty($res['ok']) && (string)($res['error'] ?? '') !== '') { + $warns[] = (string)($f['name'] ?: '附件') . ':' . $res['error']; + } + } + return $warns; +} + +/** + * 月视图网格的起止日期(**含前后补白的整周**)。 + * + * ⚠️ 网格必须按整周补白,否则"这个月 1 号是周三"时第一行会空出 3 格而没法画 —— + * 补白的日子要一起取日程(跨周/跨月的日程也落在网格里)。 + * @return array{0:string,1:string} ['Y-m-d', 'Y-m-d'] + */ +function month_grid_range(string $ym): array +{ + $firstTs = strtotime($ym . '-01'); + if ($firstTs === false) { + $firstTs = strtotime(date('Y-m') . '-01'); + } + $w = (int)date('w', $firstTs); // 0=周日 … 6=周六 + $start = strtotime('-' . $w . ' days', $firstTs); + $cells = $w + (int)date('t', $firstTs); + $rows = (int)ceil($cells / 7); + $endTs = strtotime('+' . ($rows * 7 - 1) . ' days', $start); + return [date('Y-m-d', $start), date('Y-m-d', $endTs ?: $start)]; +} + +/** + * 把一段日期里的日程摆成 6×7 的月网格。 + * + * @return array 周 => [7 格],每格 ['date','day','in_month','is_today','events'] + */ +function build_month_grid(string $ym, array $events): array +{ + [$from, $to] = month_grid_range($ym); + $byDay = []; + foreach ($events as $e) { + $byDay[(string)$e['date']][] = $e; + } + $weeks = []; + $cur = $from; + for ($r = 0; $r < 6; $r++) { + $row = []; + for ($d = 0; $d < 7; $d++) { + $row[] = [ + 'date' => $cur, + 'day' => (int)date('j', strtotime($cur)), + 'in_month' => substr($cur, 0, 7) === $ym, + 'is_today' => $cur === date('Y-m-d'), + 'events' => $byDay[$cur] ?? [], + ]; + $cur = date('Y-m-d', strtotime($cur . ' +1 day')); + } + $weeks[] = $row; + if (strcmp($cur, $to) > 0) { + break; + } + } + return $weeks; +} + +/** + * 读任务的「截止日期(+ 可选时刻)」。 + * + * ⚠️ 只填日期、不填时刻时存 00:00:00,而 00:00 在展示层与日历里都当作**全天** + * (与日程同一个口径):"某天之前做完"本来就没有具体几点。 + */ +function read_due_input(): ?string +{ + $d = trim((string)($_POST['due_date'] ?? '')); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) { + return null; + } + $t = trim((string)($_POST['due_time'] ?? '')); + return $d . ' ' . (preg_match('/^\d{2}:\d{2}$/', $t) ? $t . ':00' : '00:00:00'); +} + +/** + * 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。 + * + * ⚠️ 同一天太窄时会一条都没有(尤其刚用不久),所以**退回同月**: + * 先给"就是这天",不够再补"这个月"的往年记录。标题由调用方按实际命中改写。 + * + * @return array{rows:array,exact:bool} exact=true 表示命中的真是"同一天" + */ +function home_memories(int $familyId, int $limit = 6): array +{ + $today = date('Y-m-d'); + $year = (int)date('Y'); + $sql = "SELECT e.id, e.title, e.starts_at, e.kind, + (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS n + FROM events e + WHERE e.family_id = ? AND e.kind IN ('movie','trip') AND e.starts_at IS NOT NULL + AND YEAR(e.starts_at) < ? + AND DATE_FORMAT(e.starts_at, '%s') = ? + ORDER BY e.starts_at DESC LIMIT %d"; + $st = Database::get()->prepare(sprintf($sql, '%m-%d', $limit)); + $st->execute([$familyId, $year, substr($today, 5, 5)]); + $rows = $st->fetchAll(); + $exact = true; + if (!$rows) { + $st = Database::get()->prepare(sprintf($sql, '%m', $limit)); + $st->execute([$familyId, $year, substr($today, 5, 2)]); + $rows = $st->fetchAll(); + $exact = false; + } + foreach ($rows as &$r) { + $r['years_ago'] = $year - (int)substr((string)$r['starts_at'], 0, 4); + $r['href'] = url($r['kind'] === 'trip' ? 'trips/show' : 'movies/show', ['id' => (int)$r['id']]); + } + unset($r); + return ['rows' => $rows, 'exact' => $exact]; +} + +/** 只认本家庭、确实存在的 person_id(表单可以改,不能凭它塞进别人家的人) */ +function filter_family_person_ids(array $ids, int $familyId): array +{ + $map = Person::map($familyId); + $out = []; + foreach ($ids as $v) { + $v = (int)$v; + if ($v > 0 && isset($map[$v]) && !in_array($v, $out, true)) { + $out[] = $v; + } + } + return $out; +} + switch ($r) { @@ -294,6 +459,22 @@ switch ($r) { } unset($b); + // ---- dashboard 的另外三块(用户第 13 条)---- + // 未来 7 天的日程:自定义日程 + 观影 + 旅行 + 有截止时间的任务,一次汇总 + $cals = Calendar::mine($familyId, $me); + $today = date('Y-m-d'); + $soon = Calendar::month($familyId, $cals, $me, $today, date('Y-m-d', strtotime('+7 days'))); + // 进行中的任务 + 已经过期的待办(**只有这些才值得占首页**; + // 没截止日期的待办放任务看板里看,堆到首页会变成一张长长的清单) + $board = Task::board($familyId, $me); + $tasks = $board['doing']; + foreach ($board['todo'] as $t) { + if (Task::isOverdue($t['due_at'] ?? null)) { + $tasks[] = $t; + } + } + $memories = home_memories($familyId); + View::render('home', [ 'title' => '首页', 'family' => Family::find($familyId), @@ -303,6 +484,9 @@ switch ($r) { 'allCount' => count($people), 'relCount' => Relations::count($familyId), 'birthdays' => $birthdays, + 'soon' => $soon, + 'tasks' => $tasks, + 'memories' => $memories, // 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页) 'mySlogan' => trim((string)($people[$me]['slogan'] ?? '')), 'myNicknames'=> Nickname::mapFor($me), @@ -573,6 +757,73 @@ switch ($r) { redirect('settings/me'); break; + /** + * 保存「导航条上显示哪些模块、什么顺序」(用户第 10 条,每人一份)。 + * + * 表单同时可能带 `move=up:` / `move=down:`(▲▼ 按钮)—— 那是 + * **不用 JS 也能排序**的兜底:先在当前顺序里跟邻居换一位,再一起存。 + * 拖动则由前端直接搬 DOM(每个
  • 自带 order[],搬 li 就等于搬顺序)。 + */ + case 'settings/nav_save': + Auth::requireLogin(); + csrf_check(); + $me = (int)Auth::personId(); + $order = array_values(array_map('strval', (array)($_POST['order'] ?? []))); + $on = array_map('strval', (array)($_POST['on'] ?? [])); + // ⚠️ 只收认识的 key,且**按 order 的顺序**取交集(顺序就是用户拖出来的顺序) + $keys = array_values(array_filter( + $order, + static fn($k) => Module::exists($k) && in_array($k, $on, true) + )); + $move = (string)($_POST['move'] ?? ''); + if (strpos($move, ':') !== false) { + [$dir, $key] = explode(':', $move, 2); + $dir = $dir === 'up' ? 'up' : 'down'; + // ▲▼ 作用于**全部模块**的排列(含未勾选的),否则关掉的那些会被挤走 + $all = array_values(array_filter($order, static fn($k) => Module::exists($k))); + foreach (Module::keys() as $k) { + if (!in_array($k, $all, true)) { + $all[] = $k; + } + } + $i = array_search($key, $all, true); + if ($i !== false) { + $j = $dir === 'up' ? (int)$i - 1 : (int)$i + 1; + if ($j >= 0 && $j < count($all)) { + [$all[$i], $all[$j]] = [$all[$j], $all[$i]]; + } + } + $keys = array_values(array_filter($all, static fn($k) => in_array($k, $on, true))); + } + if (!$keys) { + $keys = Module::DEFAULT_NAV; // 一个都不勾 = 没配过,退回默认(不然导航条会空掉) + } + Module::saveNav($me, $keys); + flash('ok', '导航条已更新。'); + redirect('settings/me', [], 'modules'); + break; + + /** + * 保存模块主题色(用户第 11 条,**家庭一份**,所以只有管理员能改)。 + */ + case 'settings/module_colors': + Auth::requireLogin(); + csrf_check(); + $familyId = (int)Auth::familyId(); + if (!Auth::isAdmin()) { + flash('err', '模块主题色是全家共用的一套,只有管理员能改。'); + redirect('settings/me'); + } + if (!empty($_POST['reset'])) { + Module::saveColors($familyId, []); + flash('ok', '已恢复默认配色。'); + redirect('settings/me', [], 'modules'); + } + Module::saveColors($familyId, (array)($_POST['c'] ?? [])); + flash('ok', '模块主题色已保存。刷新页面就能看到。'); + redirect('settings/me', [], 'modules'); + break; + // ==================== 分组的顺序 ==================== // 两套:「我自己的顺序」(只影响我看到的)与「公共分组的默认顺序」(全家兜底)。 // 靠表单里的 scope 字段区分 —— 同一个动作,落到两个地方。 @@ -776,6 +1027,10 @@ switch ($r) { } try { $id = Person::create($familyId, $d); + // 头像主色:上传时已算好,这里顺手存下(算不出就不写,走姓名兜底) + if ($up['ok']) { + Person::setAvatarColor($id, (string)($up['color'] ?? '')); + } } catch (\PDOException $ex) { set_old($_POST); View::render('people/form', [ @@ -791,8 +1046,16 @@ switch ($r) { exit; } // 我加的人,自动进**我的**名册(名册是每人一份的,v5 起)。 - // 分组先留空 —— 用户可以在名册里拖到对应那一栏,也可以在那张卡片的「调整分组」里选。 - PersonRoster::ensure($familyId, (int)Auth::personId(), $id, null, 'self'); + // 分组:表单里选了就用它(用户第 12 条「增加名册时可选择所属分组」); + // ⚠️ 只认**我自己**的分组列表里的 id —— 表单可以改,不能凭它把人塞进 + // 别人私有的分组、或根本不存在的分组里。 + $gid = (int)($_POST['group_id'] ?? 0); + $okGids = array_map('intval', array_column( + PersonGroup::orderFor($familyId, (int)Auth::personId()), 'id')); + if ($gid > 0 && !in_array($gid, $okGids, true)) { + $gid = 0; + } + PersonRoster::ensure($familyId, (int)Auth::personId(), $id, $gid > 0 ? $gid : null, 'self'); Person::replaceContacts($id, $contacts); clear_old(); if ($avatarWarn !== '') { @@ -900,6 +1163,8 @@ switch ($r) { if ($up['ok']) { Upload::remove($familyId, (string)$p['avatar']); $d['avatar'] = $up['file']; + // 换图就换色:新头像的主色马上覆盖旧的(算不出时保留旧值,不清空) + Person::setAvatarColor($id, (string)($up['color'] ?? '')); } try { // 改自己且没有编名册的权限 → 只能动属于自己的字段(Person::updateSelf)。 @@ -1357,7 +1622,7 @@ switch ($r) { case 'movies': $familyId = (int)Auth::familyId(); $keyword = trim((string)($_GET['keyword'] ?? '')); - $rows = Movie::all($familyId, $keyword); + $rows = Movie::all($familyId, $keyword, (int)Auth::personId()); View::render('movies/index', [ 'title' => '观影记录', 'rows' => $rows, @@ -1368,12 +1633,12 @@ switch ($r) { case 'movies/show': $familyId = (int)Auth::familyId(); - $m = Movie::find($familyId, (int)($_GET['id'] ?? 0)); + $meId = (int)Auth::personId(); + $m = Movie::find($familyId, (int)($_GET['id'] ?? 0), $meId); if (!$m) { flash('err', '找不到这条观影记录。'); redirect('movies'); } - $meId = (int)Auth::personId(); $rvError = ''; View::render('movies/show', [ 'title' => (string)$m['title'], @@ -1401,7 +1666,7 @@ switch ($r) { case 'movies/edit': Auth::requireWrite(); $familyId = (int)Auth::familyId(); - $m = Movie::find($familyId, (int)($_GET['id'] ?? 0)); + $m = Movie::find($familyId, (int)($_GET['id'] ?? 0), (int)Auth::personId()); if (!$m) { flash('err', '找不到这条观影记录。'); redirect('movies'); @@ -1781,7 +2046,7 @@ switch ($r) { 'title' => '观影统计', 'ov' => MovieStats::overview($familyId), 'byCinema' => MovieStats::byCinema($familyId), - 'byPerson' => MovieStats::byPerson($familyId), + 'byPerson' => MovieStats::byPerson($familyId, (int)Auth::personId()), 'byMonth' => MovieStats::byMonth($familyId, 12), 'byYear' => MovieStats::byYear($familyId), ], 'movies'); @@ -1810,7 +2075,7 @@ switch ($r) { 'persons' => Person::all($familyId, '', 'member', (int)Auth::personId()), 'p' => $p, 'hasCond' => $hasCond, - 'rows' => $hasCond ? MovieStats::search($familyId, $p) : [], + 'rows' => $hasCond ? MovieStats::search($familyId, $p, (int)Auth::personId()) : [], ], 'movies'); break; @@ -1922,6 +2187,932 @@ switch ($r) { redirect('movies/show', ['id' => $eventId], 'reviews'); break; + // ==================== 旅程(v12,TripLog 迁入) ==================== + // 与观影共用 events 骨架(kind='trip'):一次出行 = 一个有起止、有人、会上日历的事件。 + // 旅程特有的(封面 / 小结)放 trip_details,行程段落放 trip_nodes(计划 / 执行双轨)。 + + case 'trips': + $familyId = (int)Auth::familyId(); + $keyword = trim((string)($_GET['keyword'] ?? '')); + $rows = Trip::all($familyId, $keyword); + Trip::attachAttendees($rows, (int)Auth::personId()); + View::render('trips/index', [ + 'title' => '旅程', + 'rows' => $rows, + 'total' => count($rows), + 'keyword' => $keyword, + ], 'trips'); + break; + + case 'trips/new': + Auth::requireWrite(); + View::render('trips/form', [ + 'title' => '记一趟旅程', + 't' => null, + // 新建时默认勾上「我自己」—— 一趟旅程里几乎总有我,少点一下 + 'presetAttendees' => [(int)Auth::personId()], + 'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''), + 'errors' => [], + ], 'trips'); + break; + + case 'trips/edit': + Auth::requireWrite(); + $familyId = (int)Auth::familyId(); + $t = Trip::find($familyId, (int)($_GET['id'] ?? 0), (int)Auth::personId()); + if (!$t) { + flash('err', '找不到这趟旅程。'); + redirect('trips'); + } + View::render('trips/form', [ + 'title' => '编辑旅程', + 't' => $t, + 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), + 'errors' => [], + ], 'trips'); + break; + + case 'trips/create': + case 'trips/update': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $isEdit = $r === 'trips/update'; + $id = $isEdit ? (int)($_POST['id'] ?? 0) : 0; + $t = $isEdit ? Trip::find($familyId, $id) : null; + if ($isEdit && !$t) { + flash('err', '找不到这趟旅程。'); + redirect('trips'); + } + $startsAt = read_dt_input('starts_at'); + $endsAt = read_dt_input('ends_at'); + $d = [ + 'title' => trim((string)($_POST['title'] ?? '')), + 'starts_at' => (string)($startsAt ?? ''), + 'ends_at' => (string)($endsAt ?? ''), + 'location' => trim((string)($_POST['location'] ?? '')), + 'note' => trim((string)($_POST['note'] ?? '')), + 'summary' => trim((string)($_POST['summary'] ?? '')), + 'attendees' => filter_family_person_ids((array)($_POST['attendees'] ?? []), $familyId), + ]; + $errors = []; + if ($d['title'] === '') { + $errors['title'] = '旅程名字不能为空'; + } elseif ($d['starts_at'] !== '' && $d['ends_at'] !== '' + && strcmp($d['ends_at'], $d['starts_at']) < 0) { + $errors['ends_at'] = '回来的时间不能早于出发'; + } + // 封面图(编辑时换新图才动它;失败不挡保存,与观影海报同一个口径) + $oldCover = $isEdit ? (string)($t['cover_key'] ?? '') : ''; + $d['cover_key'] = $oldCover; + $warns = []; + $up = Upload::image($familyId, $_FILES['cover'] ?? [], 'photo'); + if ($up['ok']) { + $d['cover_key'] = (string)$up['file']; + if ($oldCover !== '' && $oldCover !== $d['cover_key']) { + \App\Storage\Storage::delete($familyId, $oldCover); + } + } elseif ((string)($up['error'] ?? '') !== '') { + $warns[] = '封面图没有保存:' . $up['error']; + } + if ($errors) { + set_old($_POST); + View::render('trips/form', [ + 'title' => $isEdit ? '编辑旅程' : '记一趟旅程', + 't' => $isEdit ? array_merge($t, $d) : null, + 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), + 'errors' => $errors, + ], 'trips'); + exit; + } + if ($isEdit) { + Trip::update($familyId, $id, $d); + clear_old(); + flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); + redirect('trips/show', ['id' => $id]); + } + $newId = Trip::create($familyId, (int)Auth::personId(), $d); + clear_old(); + flash('ok', '这趟旅程记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); + redirect('trips/show', ['id' => $newId]); + break; + + case 'trips/show': + $familyId = (int)Auth::familyId(); + $meId = (int)Auth::personId(); + $t = Trip::find($familyId, (int)($_GET['id'] ?? 0), $meId); + if (!$t) { + flash('err', '找不到这趟旅程。'); + redirect('trips'); + } + View::render('trips/show', [ + 'title' => (string)$t['title'], + 't' => $t, + 'photos' => $t['photos'], + 'nodes' => Trip::nodes((int)$t['id'], $meId), + 'people' => Person::roster($familyId, $meId, 'member', ''), + ], 'trips'); + break; + + case 'trips/destroy': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + if (!Trip::find($familyId, $id)) { + flash('err', '找不到这趟旅程。'); + redirect('trips'); + } + Trip::destroy($familyId, $id); + flash('ok', '这趟旅程已删除。'); + redirect('trips'); + break; + + case 'trips/photo_add': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + if (!Trip::find($familyId, $id)) { + flash('err', '找不到这趟旅程。'); + redirect('trips'); + } + $warns = attach_save_files($familyId, 'trip', $id, 'photo', (int)Auth::personId()); + if ($warns) { + flash('err', '有照片没有保存:' . implode(';', $warns)); + } else { + flash('ok', '照片已加上。'); + } + redirect('trips/show', ['id' => $id]); + break; + + case 'trips/photo_del': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + if (!Trip::find($familyId, $id)) { + flash('err', '找不到这趟旅程。'); + redirect('trips'); + } + \App\Attachment::remove((int)($_POST['photo_id'] ?? 0)); + flash('ok', '照片已删除。'); + redirect('trips/show', ['id' => $id]); + break; + + // ---- 行程节点(计划 / 执行双轨)---- + + case 'trips/node_add': + case 'trips/node_update': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $tripId = (int)($_POST['trip_id'] ?? 0); + $t = Trip::find($familyId, $tripId); + if (!$t) { + flash('err', '找不到这趟旅程。'); + redirect('trips'); + } + $isEdit = $r === 'trips/node_update'; + $nid = $isEdit ? (int)($_POST['id'] ?? 0) : 0; + $d = [ + 'title' => trim((string)($_POST['title'] ?? '')), + 'detail' => trim((string)($_POST['detail'] ?? '')), + 'place' => trim((string)($_POST['place'] ?? '')), + 'starts_at' => (string)(read_dt_input('starts_at') ?? ''), + 'ends_at' => (string)(read_dt_input('ends_at') ?? ''), + 'node_type' => (string)($_POST['node_type'] ?? 'other'), + 'status' => (string)($_POST['status'] ?? 'planned'), + 'category' => (string)($_POST['category'] ?? 'plan'), + 'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId), + ]; + if ($d['title'] === '') { + flash('err', '这一段得有个名字。'); + redirect('trips/show', ['id' => $tripId]); + } + $newId = Trip::saveNode($familyId, $tripId, $d, $isEdit ? $nid : null); + $warns = attach_save_files($familyId, 'trip_node', $newId, 'attach', (int)Auth::personId()); + flash('ok', $isEdit ? '这一段已保存。' : '已加一段。' + . ($warns ? '(' . implode(';', $warns) . ')' : '')); + redirect('trips/show', ['id' => $tripId]); + break; + + case 'trips/node_del': + $familyId = (int)Auth::familyId(); + Auth::requireWrite(); + csrf_check(); + $tripId = (int)($_POST['trip_id'] ?? 0); + if (!Trip::find($familyId, $tripId)) { + flash('err', '找不到这趟旅程。'); + redirect('trips'); + } + Trip::deleteNode((int)($_POST['id'] ?? 0)); + flash('ok', '这一段已删除。'); + redirect('trips/show', ['id' => $tripId]); + break; + + // ==================== 日程 / 日历(v12) ==================== + // 自定义日程落在 calendar_events;**观影与旅行两本是派生的**(从 events 实时汇总, + // 不复制),给这两本"加日程"= 真的建一条观影 / 旅程记录。 + + case 'calendar': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + $ym = trim((string)($_GET['ym'] ?? '')); + if (!preg_match('/^\d{4}-\d{2}$/', $ym)) { + $ym = date('Y-m'); + } + $cals = Calendar::mine($familyId, $me); + [$from, $to] = month_grid_range($ym); + $events = Calendar::month($familyId, $cals, $me, $from, $to); + View::render('calendar/index', [ + 'title' => '日程', + 'ym' => $ym, + 'weeks' => build_month_grid($ym, $events), + 'cals' => $cals, + 'people' => Person::roster($familyId, $me, 'member', ''), + ], 'calendar'); + break; + + case 'calendar/calendars': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + $all = Calendar::mine($familyId, $me); + $mine = []; + $shared = []; + foreach ($all as $c) { + if ((int)$c['shared'] === 1) { + $shared[] = $c; + } else { + $mine[] = $c; + } + } + $shares = []; + foreach ($mine as $c) { + $shares[(int)$c['id']] = Calendar::shares((int)$c['id']); + } + $editing = null; + $editId = (int)($_GET['edit'] ?? 0); + if ($editId > 0) { + foreach ($mine as $c) { + if ((int)$c['id'] === $editId && (string)$c['kind'] === 'custom') { + $editing = $c; + } + } + } + View::render('calendar/calendars', [ + 'title' => '日历本', + 'cals' => $mine, + 'shared' => $shared, + 'shares' => $shares, + 'editing' => $editing, + 'people' => Person::roster($familyId, $me, 'member', ''), + ], 'calendar'); + break; + + case 'calendar/cal_add': + case 'calendar/cal_update': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $name = trim((string)($_POST['name'] ?? '')); + $color = trim((string)($_POST['color'] ?? '')); + if ($name === '') { + flash('err', '日历本得有个名字。'); + redirect('calendar/calendars'); + } + if ($r === 'calendar/cal_update') { + $id = (int)($_POST['id'] ?? 0); + // 只改自己的自定义本:预置的观影/旅行本不能改名换色(内容是从模块派生的) + $cal = Calendar::find($id); + if (!$cal || (int)$cal['owner_id'] !== $me || (string)$cal['kind'] !== 'custom') { + flash('err', '这本日历不能改。'); + redirect('calendar/calendars'); + } + Calendar::update($id, $name, $color); + flash('ok', '日历本已更新。'); + } else { + Calendar::create($familyId, $me, $name, $color); + flash('ok', '日历本已新建。'); + } + redirect('calendar/calendars'); + break; + + case 'calendar/cal_del': + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $cal = Calendar::find($id); + if (!$cal || (int)$cal['owner_id'] !== $me) { + flash('err', '这本日历不能删。'); + redirect('calendar/calendars'); + } + Calendar::delete($id); // 内部只删 kind='custom' + flash('ok', '日历本已删除。'); + redirect('calendar/calendars'); + break; + + case 'calendar/cal_share': + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $cal = Calendar::find($id); + $target = (int)($_POST['person_id'] ?? 0); + if (!$cal || (int)$cal['owner_id'] !== $me || $target <= 0 || $target === $me) { + flash('err', '不能这样共享。'); + redirect('calendar/calendars'); + } + Calendar::setShare($id, $target, (string)($_POST['perm'] ?? 'read')); + flash('ok', '已共享。'); + redirect('calendar/calendars'); + break; + + case 'calendar/cal_unshare': + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $cal = Calendar::find($id); + if (!$cal || (int)$cal['owner_id'] !== $me) { + flash('err', '不能这样取消共享。'); + redirect('calendar/calendars'); + } + Calendar::dropShare($id, (int)($_POST['person_id'] ?? 0)); + flash('ok', '已取消共享。'); + redirect('calendar/calendars'); + break; + + case 'calendar/event_add': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $ym = trim((string)($_POST['ym'] ?? '')); + $ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m'); + $calId = (int)($_POST['calendar_id'] ?? 0); + $cal = null; + foreach (Calendar::mine($familyId, $me) as $c) { + if ((int)$c['id'] === $calId) { + $cal = $c; + } + } + if (!$cal || !Calendar::canWrite($cal, $me)) { + flash('err', '这本日历你没有写权限。'); + redirect('calendar', ['ym' => $ym]); + } + $date = trim((string)($_POST['date'] ?? '')); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + flash('err', '日期不对。'); + redirect('calendar', ['ym' => $ym]); + } + $endDate = trim((string)($_POST['end_date'] ?? '')); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) { + $endDate = null; + } + $title = trim((string)($_POST['title'] ?? '')); + if ($title === '') { + flash('err', '日程得有个标题。'); + redirect('calendar', ['ym' => $ym]); + } + $allDay = !empty($_POST['all_day']); + $st = $allDay ? '' : (string)($_POST['start_time'] ?? ''); + $et = $allDay ? '' : (string)($_POST['end_time'] ?? ''); + $note = trim((string)($_POST['note'] ?? '')); + + // ⚠️ 观影 / 旅行两本是派生的:往这两本里加日程 = 建一条真正的观影 / 旅程记录, + // 而不是往 calendar_events 里塞一行(那样日历里有、观影列表里没有)。 + $starts = $date . ' ' . ($st !== '' && preg_match('/^\d{2}:\d{2}$/', $st) ? $st . ':00' : '00:00:00'); + $ends = ($endDate ?: $date) . ' ' . ($et !== '' && preg_match('/^\d{2}:\d{2}$/', $et) ? $et . ':00' : '00:00:00'); + $kind = (string)$cal['kind']; + if ($kind === 'movie') { + $newId = Movie::create($familyId, $me, [ + 'title' => $title, 'starts_at' => $starts, 'ends_at' => $ends, 'note' => $note, + 'attendees' => [$me], + ]); + flash('ok', '已加进观影,日历里也能看到了。'); + redirect('movies/show', ['id' => $newId]); + } + if ($kind === 'trip') { + $newId = Trip::create($familyId, $me, [ + 'title' => $title, 'starts_at' => $starts, 'ends_at' => $ends, 'note' => $note, + 'attendees' => [$me], + ]); + flash('ok', '已加进旅程,日历里也能看到了。'); + redirect('trips/show', ['id' => $newId]); + } + Calendar::addEvent((int)$cal['id'], [ + 'title' => $title, + 'starts_at' => $date, + 'ends_at' => $endDate, + 'start_time' => $st !== '' ? $st . ':00' : null, + 'end_time' => $et !== '' ? $et . ':00' : null, + 'all_day' => $allDay, + 'note' => $note, + 'created_by' => $me, + ]); + flash('ok', '日程已加上。'); + redirect('calendar', ['ym' => substr($date, 0, 7)]); + break; + + case 'calendar/event': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + $id = (int)($_GET['id'] ?? 0); + $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 = ? AND c.family_id = ?' + ); + $st->execute([$id, $familyId]); + $ev = $st->fetch(); + if (!$ev) { + flash('err', '找不到这条日程。'); + redirect('calendar'); + } + $cal = Calendar::find((int)$ev['calendar_id']) ?: []; + $ev['_can_write'] = Calendar::canWrite($cal, $me); + View::render('calendar/event', [ + 'title' => (string)$ev['title'], + 'ev' => $ev, + 'cal' => $cal, + ], 'calendar'); + break; + + case 'calendar/event_update': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $ym = trim((string)($_POST['ym'] ?? '')); + $ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m'); + $st = Database::get()->prepare( + 'SELECT e.id, e.calendar_id, c.owner_id FROM calendar_events e + JOIN calendars c ON c.id = e.calendar_id + WHERE e.id = ? AND c.family_id = ?' + ); + $st->execute([$id, $familyId]); + $ev = $st->fetch(); + if (!$ev) { + flash('err', '找不到这条日程。'); + redirect('calendar', ['ym' => $ym]); + } + $cal = Calendar::find((int)$ev['calendar_id']) ?: []; + if (!Calendar::canWrite($cal, $me)) { + flash('err', '这本日历你没有写权限。'); + redirect('calendar', ['ym' => $ym]); + } + $date = trim((string)($_POST['date'] ?? '')); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + flash('err', '日期不对。'); + redirect('calendar', ['ym' => $ym]); + } + $allDay = !empty($_POST['all_day']); + $endDate = trim((string)($_POST['end_date'] ?? '')); + Calendar::updateEvent($id, [ + 'title' => trim((string)($_POST['title'] ?? '')), + 'starts_at' => $date, + 'ends_at' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) ? $endDate : null, + 'start_time' => !$allDay && preg_match('/^\d{2}:\d{2}$/', (string)($_POST['start_time'] ?? '')) + ? (string)$_POST['start_time'] . ':00' : null, + 'end_time' => !$allDay && preg_match('/^\d{2}:\d{2}$/', (string)($_POST['end_time'] ?? '')) + ? (string)$_POST['end_time'] . ':00' : null, + 'all_day' => $allDay, + 'note' => trim((string)($_POST['note'] ?? '')), + ]); + flash('ok', '日程已保存。'); + redirect('calendar', ['ym' => substr($date, 0, 7)]); + break; + + case 'calendar/event_del': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $ym = trim((string)($_POST['ym'] ?? '')); + $ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m'); + $st = Database::get()->prepare( + 'SELECT e.id, e.calendar_id FROM calendar_events e + JOIN calendars c ON c.id = e.calendar_id + WHERE e.id = ? AND c.family_id = ?' + ); + $st->execute([$id, $familyId]); + $ev = $st->fetch(); + if ($ev) { + $cal = Calendar::find((int)$ev['calendar_id']) ?: []; + if (Calendar::canWrite($cal, $me)) { + Calendar::deleteEvent($id); + flash('ok', '日程已删除。'); + } else { + flash('err', '这本日历你没有写权限。'); + } + } + redirect('calendar', ['ym' => $ym]); + break; + + // ==================== 资源(v12) ==================== + // 挂在**某个人名下**的东西(证件 / 照片 / 账号),可共享给别人(只读或读写)。 + + case 'resources': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + $keyword = trim((string)($_GET['keyword'] ?? '')); + $ownerId = (int)($_GET['owner'] ?? 0); + $rows = Resource::all($familyId, $me, $ownerId, $keyword); + // 附件与共享一次取齐(列表里每条都要显示"几个附件""共享给谁") + $attachs = []; + $shares = []; + foreach ($rows as $r) { + $attachs[(int)$r['id']] = \App\Attachment::list(Resource::ATTACH, (int)$r['id']); + $shares[(int)$r['id']] = Resource::shares((int)$r['id']); + } + $editing = null; + $editId = (int)($_GET['edit'] ?? 0); + if ($editId > 0) { + $editing = Resource::find($familyId, $editId, $me); + if ($editing !== null && !Resource::canWrite($editing, $me)) { + $editing = null; // 只读的不给编辑(表单照旧收起) + } + } + View::render('resources/index', [ + 'title' => '资源', + 'rows' => $rows, + 'attachs' => $attachs, + 'shares' => $shares, + 'editing' => $editing, + 'keyword' => $keyword, + 'ownerId' => $ownerId, + 'people' => Person::roster($familyId, $me, 'member', ''), + ], 'resources'); + break; + + case 'resources/create': + case 'resources/update': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $isEdit = $r === 'resources/update'; + $id = $isEdit ? (int)($_POST['id'] ?? 0) : 0; + $d = [ + 'title' => trim((string)($_POST['title'] ?? '')), + 'content' => trim((string)($_POST['content'] ?? '')), + ]; + if ($d['title'] === '') { + flash('err', '资源得有个标题。'); + redirect('resources'); + } + $ownerId = (int)($_POST['owner_id'] ?? 0); + $map = Person::map($familyId); + if (!isset($map[$ownerId])) { + $ownerId = $me; // 表单可以改,但不能指到不存在的人身上 + } + if ($isEdit) { + $old = Resource::find($familyId, $id, $me); + if (!$old || !Resource::canWrite($old, $me)) { + flash('err', '这条资源不能改。'); + redirect('resources'); + } + Resource::update($familyId, $id, $d); + // 允许改归属人(记错了挂在谁名下) + if ((int)$old['owner_id'] !== $ownerId) { + Database::get()->prepare('UPDATE resources SET owner_id = ? WHERE id = ? AND family_id = ?') + ->execute([$ownerId, $id, $familyId]); + } + $rid = $id; + flash('ok', '资源已保存。'); + } else { + $rid = Resource::create($familyId, $ownerId, $me, $d); + flash('ok', '资源已新建。'); + } + $warns = attach_save_files($familyId, Resource::ATTACH, $rid, 'attach', $me); + if ($warns) { + flash('err', '有附件没有保存:' . implode(';', $warns)); + } + redirect('resources'); + break; + + case 'resources/attach_add': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $r0 = Resource::find($familyId, $id, $me); + if (!$r0 || !Resource::canWrite($r0, $me)) { + flash('err', '这条资源不能改。'); + redirect('resources'); + } + $warns = attach_save_files($familyId, Resource::ATTACH, $id, 'attach', $me); + flash($warns ? 'err' : 'ok', $warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。'); + redirect('resources'); + break; + + case 'resources/attach_del': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $aid = (int)($_POST['id'] ?? 0); + // ⚠️ 要按"这条附件属于哪条资源"找到宿主再判权限:只传附件 id 的话, + // 谁都能构造一个请求删掉别人家的附件。 + $st = Database::get()->prepare('SELECT target_id FROM attachments WHERE id = ? AND target_type = ?'); + $st->execute([$aid, Resource::ATTACH]); + $tid = (int)$st->fetchColumn(); + $r0 = $tid > 0 ? Resource::find($familyId, $tid, $me) : null; + if (!$r0 || !Resource::canWrite($r0, $me)) { + flash('err', '这个附件不能删。'); + redirect('resources'); + } + \App\Attachment::remove($aid); + flash('ok', '附件已删除。'); + redirect('resources'); + break; + + case 'resources/share': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $r0 = Resource::find($familyId, $id, $me); + if (!$r0 || !Resource::canWrite($r0, $me)) { + flash('err', '这条资源不能共享。'); + redirect('resources'); + } + $target = (int)($_POST['person_id'] ?? 0); + if ($target > 0 && isset(Person::map($familyId)[$target])) { + Resource::setShare($id, (int)$r0['owner_id'], $target, (string)($_POST['perm'] ?? 'read')); + flash('ok', '已共享。'); + } + redirect('resources'); + break; + + case 'resources/unshare': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $r0 = Resource::find($familyId, $id, $me); + if ($r0 && Resource::canWrite($r0, $me)) { + Resource::dropShare($id, (int)($_POST['person_id'] ?? 0)); + flash('ok', '已取消共享。'); + } + redirect('resources'); + break; + + case 'resources/destroy': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $r0 = Resource::find($familyId, $id, $me); + if (!$r0 || !Resource::canWrite($r0, $me)) { + flash('err', '这条资源不能删。'); + redirect('resources'); + } + Resource::destroy($familyId, $id); + flash('ok', '资源已删除。'); + redirect('resources'); + break; + + // ==================== 任务(v12) ==================== + + case 'tasks': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + $board = Task::board($familyId, $me); + View::render('tasks/index', [ + 'title' => '任务', + 'board' => $board, + 'total' => count($board['todo']) + count($board['doing']) + count($board['done']), + 'people' => Person::roster($familyId, $me, 'member', ''), + ], 'tasks'); + break; + + case 'tasks/show': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + $t = Task::find($familyId, (int)($_GET['id'] ?? 0), $me); + if (!$t) { + flash('err', '找不到这项任务。'); + redirect('tasks'); + } + // 可选的父任务 = 我能看到的**顶层**任务(不含自己) + $parents = []; + foreach (['todo', 'doing', 'done'] as $sk) { + foreach (Task::board($familyId, $me)[$sk] as $p) { + if ((int)$p['id'] !== (int)$t['id']) { + $parents[] = $p; + } + } + } + View::render('tasks/show', [ + 'title' => (string)$t['title'], + 't' => $t, + 'people' => Person::roster($familyId, $me, 'member', ''), + 'parents' => $parents, + ], 'tasks'); + break; + + case 'tasks/create': + case 'tasks/update': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $isEdit = $r === 'tasks/update'; + $id = $isEdit ? (int)($_POST['id'] ?? 0) : 0; + $old = $isEdit ? Task::find($familyId, $id, $me) : null; + if ($isEdit && (!$old || !Task::canWrite($old, $me))) { + flash('err', '这项任务不能改。'); + redirect('tasks'); + } + $title = trim((string)($_POST['title'] ?? '')); + if ($title === '') { + flash('err', '任务得写清楚要做什么。'); + redirect('tasks'); + } + $d = [ + 'title' => $title, + 'note' => trim((string)($_POST['note'] ?? '')), + 'due_at' => (string)(read_due_input() ?? ''), + 'repeat_rule' => trim((string)($_POST['repeat_rule'] ?? '')), + 'parent_id' => (int)($_POST['parent_id'] ?? 0), + 'assignee_id' => (int)($_POST['assignee_id'] ?? 0), + 'status' => $isEdit ? (string)$old['status'] : 'todo', + ]; + if ($d['assignee_id'] > 0 && !isset(Person::map($familyId)[$d['assignee_id']])) { + $d['assignee_id'] = 0; + } + if ($isEdit) { + Task::update($familyId, $id, $d); + $tid = $id; + flash('ok', '任务已保存。'); + } else { + $tid = Task::create($familyId, $me, $d); + foreach (filter_family_person_ids((array)($_POST['shares'] ?? []), $familyId) as $pid) { + Task::setShare($tid, $pid, 'write'); + } + $warns = attach_save_files($familyId, Task::ATTACH, $tid, 'attach', $me); + if ($warns) { + flash('err', '有附件没有保存:' . implode(';', $warns)); + } + flash('ok', '任务已新建。'); + } + redirect('tasks/show', ['id' => $tid]); + break; + + case 'tasks/move': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $status = (string)($_POST['status'] ?? ''); + $t = Task::find($familyId, $id, $me); + if (!$t || !Task::canWrite($t, $me)) { + flash('err', '这项任务不能改。'); + redirect('tasks'); + } + $before = (int)($_POST['before'] ?? 0); + // 子任务只在 todo / done 之间切(看板里没有子任务的列,给它做定位没意义) + if (!empty($t['parent_id'])) { + $status = $status === 'done' ? 'done' : 'todo'; + Task::setStatus($familyId, $id, $status); + } else { + Task::moveTo($familyId, $id, $status, $before); + } + if ($status === 'done' && !empty($t['repeat_rule']) && ($t['due_at'] ?? '') !== '') { + flash('ok', '已完成,下一次的也排上了。'); + } elseif ($status === 'done') { + flash('ok', '已完成。'); + } + redirect('tasks/show', ['id' => $id]); + break; + + case 'tasks/comment_add': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $t = Task::find($familyId, $id, $me); + if (!$t) { + flash('err', '找不到这项任务。'); + redirect('tasks'); + } + $cid = Task::addComment($id, $me, (string)($_POST['content'] ?? '')); + if ($cid > 0) { + attach_save_files($familyId, 'task_comment', $cid, 'attach', $me); + flash('ok', '留言已发表。'); + } else { + flash('err', '留言是空的。'); + } + redirect('tasks/show', ['id' => $id]); + break; + + case 'tasks/comment_del': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $taskId = (int)($_POST['task_id'] ?? 0); + Task::deleteComment((int)($_POST['id'] ?? 0), $me); // 内部只删自己的 + flash('ok', '留言已删除。'); + redirect('tasks/show', ['id' => $taskId]); + break; + + case 'tasks/attach_add': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $t = Task::find($familyId, $id, $me); + if (!$t || !Task::canWrite($t, $me)) { + flash('err', '这项任务不能改。'); + redirect('tasks'); + } + $warns = attach_save_files($familyId, Task::ATTACH, $id, 'attach', $me); + flash($warns ? 'err' : 'ok', $warns ? '有附件没有保存:' . implode(';', $warns) : '附件已上传。'); + redirect('tasks/show', ['id' => $id]); + break; + + case 'tasks/attach_del': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $aid = (int)($_POST['id'] ?? 0); + $st = Database::get()->prepare('SELECT target_id FROM attachments WHERE id = ? AND target_type = ?'); + $st->execute([$aid, Task::ATTACH]); + $tid = (int)$st->fetchColumn(); + $t = $tid > 0 ? Task::find($familyId, $tid, $me) : null; + if (!$t || !Task::canWrite($t, $me)) { + flash('err', '这个附件不能删。'); + redirect('tasks'); + } + \App\Attachment::remove($aid); + flash('ok', '附件已删除。'); + redirect('tasks/show', ['id' => $tid]); + break; + + case 'tasks/share': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $t = Task::find($familyId, $id, $me); + if (!$t || !Task::canWrite($t, $me)) { + flash('err', '这项任务不能共享。'); + redirect('tasks'); + } + $target = (int)($_POST['person_id'] ?? 0); + if ($target > 0 && isset(Person::map($familyId)[$target])) { + Task::setShare($id, $target, (string)($_POST['perm'] ?? 'write')); + flash('ok', '已共享。'); + } + redirect('tasks/show', ['id' => $id]); + break; + + case 'tasks/unshare': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $t = Task::find($familyId, $id, $me); + if ($t && Task::canWrite($t, $me)) { + Task::dropShare($id, (int)($_POST['person_id'] ?? 0)); + flash('ok', '已取消共享。'); + } + redirect('tasks/show', ['id' => $id]); + break; + + case 'tasks/destroy': + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + $id = (int)($_POST['id'] ?? 0); + $t = Task::find($familyId, $id, $me); + if (!$t || !Task::canWrite($t, $me)) { + flash('err', '这项任务不能删。'); + redirect('tasks'); + } + Task::destroy($familyId, $id); + flash('ok', '任务已删除。'); + redirect('tasks'); + break; + default: http_response_code(404); View::render('error', [ diff --git a/install.php b/install.php index 34479a3..e3c42b3 100644 --- a/install.php +++ b/install.php @@ -79,6 +79,12 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { // ⚠️ 这里**不能**把全家成员都塞给他:新装时除他之外还没有别人, // 而"别人"要靠他自己加或复用,这正是"每人一份"的意思。 \App\PersonRoster::ensure((int)$familyId, (int)$personId, (int)$personId, null, 'self'); + // 默认日历本(观影 / 旅行):**必须在这里补一遍**,因为上面那次 + // Schema::ensure() 跑的时候这个人还没建出来(ensure 里的补建遍历的是 + // "已开通账号的人")—— 只在 ensure 里做的话,装完的这个人一本日历都没有, + // 而且版本号已被盖上,升级路径也不会再来补。 + // (登录处还有第二道兜底,见 Auth::login。) + \App\Calendar::seedDefaults((int)$familyId, (int)$personId); // 装完就删掉自己 —— 避免把安装入口长期暴露在公网 @unlink(__FILE__); diff --git a/templates/calendar/calendars.php b/templates/calendar/calendars.php new file mode 100644 index 0000000..e754c1c --- /dev/null +++ b/templates/calendar/calendars.php @@ -0,0 +1,175 @@ + +
    +

    日历本

    + 共 本 + +
    + + +
    只读账号访客只能查看日历本,不能新建或修改。
    + +
    +

    +
    + + + + +
    +
    + + +
    +
    + +
    + + + + + + + +
    +
    +
    +
    + + + 取消 + +
    +
    +
    + + +
    +

    我自己的

    + +

    还没有日历本。

    + +
    + + + + + + + + + + + + + + + + +
    名字颜色共享给操作
    + + + 系统预置 + + + + + + + + 不共享 + +
    + + + + +
    + + +
    + 共享设置 +
    + + + + + +
    + +
    + + + + + +
    + +
    + +
    +
    + + 编辑 +
    + + + +
    + + 不可改 + +
    +
    +
    + +
    + + +
    +

    别人共享给我的

    +
    + + + + + + + + + + + + +
    名字谁的我的权限
    $c['owner_name'] ?? '', 'my_nickname' => $c['owner_nick'] ?? ''])) ?>
    +
    +
    + diff --git a/templates/calendar/event.php b/templates/calendar/event.php new file mode 100644 index 0000000..bef8c6b --- /dev/null +++ b/templates/calendar/event.php @@ -0,0 +1,84 @@ + +
    +

    + + +
    + +
    + +
    +
    日期
    + +
    到
    + +
    时间
    +
    +
    + +
    备注
    + +
    +

    这本日历你只有只读权限,看得到但不能改。

    + +
    + + + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + 取消 +
    +
    + 套
    是非法 HTML, + 浏览器会把外层表单在嵌套处切开,"保存"按钮就失效了。 */ ?> + + + + + +
    + +
    diff --git a/templates/calendar/index.php b/templates/calendar/index.php new file mode 100644 index 0000000..1c8d2ed --- /dev/null +++ b/templates/calendar/index.php @@ -0,0 +1,197 @@ + \App\Calendar::canWrite($c, (int)\App\Auth::personId()))); +?> +
    +

    日程

    + +
    + 上一月 + 今天 + 下一月 + 日历本 + + + +
    +
    + +
    + + + + +
    + + + + +
    +
    + +
    + +
    +
    + + +
    +
    +
    + + + + + + + + + 3): ?> + + 条 + +
    +
    + + +
    +
    +
    +
    + + diff --git a/templates/home.php b/templates/home.php index 5ca156d..be5a0b5 100644 --- a/templates/home.php +++ b/templates/home.php @@ -1,35 +1,22 @@ 0; -// 显示名一律走「我怎么称呼他」:同一个孩子,爸爸看到"儿子"、妈妈看到"小宝"。 -// ⚠️ 用**已经取好的那张表**(target_id => 我叫他什么)做本地查表, -// 不要再对每个人调一次 Nickname::display() —— 那是每人一条 SQL。 -// 没有称呼的人回落到姓名,所以永远不会出现空白名字。 -$shownOf = function (array $row) use ($myNicknames): string { - $nk = (string)($myNicknames[(int)($row['id'] ?? 0)] ?? ''); - return $nk !== '' ? $nk : (string)($row['name'] ?? ''); -}; -// 有亲属关系 → 只显示家人;还没建 → 退回显示全名册,别让首页空着 -$list = $hasRel ? $kin : []; -if (!$list) { - foreach (\App\Person::all((int)\App\Auth::familyId(), '', 'member', (int)$meId) as $p) { - $p['label'] = ''; - $list[(int)$p['id']] = $p; - } -} - // 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出 $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '农历生日']; +$today = date('Y-m-d'); ?>
    @@ -41,11 +28,26 @@ $shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '
    名册总数
    亲属连线
  • + + - -
    + +

    接下来的日子 两个月内

    + +
    近两个月没有人过生日等到了月份,这里会自动冒出来。
    +
    '身份证生日', 'solar' => '阳历生日', 'lunar' => '
    +
    - +
    -

    +

    未来七天 日程 · 观影 · 旅行 · 到期任务

    - - -
    - 先把家人连起来 - 目前还没有任何亲属关系,所以下面显示的是整个名册。
    - 到自己的资料页填上「谁是我的父母、配偶、子女」,之后这一页就只显示跟你有关的人, - 每个人的称呼也会按你的视角来算。 - - - -
    - - - -
    - 名册还是空的 - 先把家里人加进来,之后他们就能出现在每一条记录里。 - -
    + +
    这七天还没有安排有事先记一笔,日后再翻才有得看。
    -
    + + +
    +
    +

    手头在做 进行中 · 已逾期

    + +
    + +
    没有进行中的任务也没有逾期的事,挺好。
    + +
    +
      + + +
    • + + + + + + 进行中 - 管理员'; - } elseif ($role === 'guest') { - echo '访客'; - } elseif (!empty($p['account_id'])) { - echo '已开通'; - } - $age = age_from(\App\Birthday::solarOf($p)); - if ($age !== null) { echo ' · ' . (int)$age . ' 岁'; } - ?> -
    - - - + + + + + 0): ?> + / + + + + + + +
    + + +
    +
    +

    + + +

    +
    + +
    + + 日子还长,等有了第一次,明年这时候就会自己冒出来。 +
    + +
    +
      + +
    • + + 年前 + + + + +
    • + +
    diff --git a/templates/kin/tree.php b/templates/kin/tree.php index c95a465..092ea5c 100644 --- a/templates/kin/tree.php +++ b/templates/kin/tree.php @@ -34,41 +34,102 @@ $nodes = $tree['nodes']; $me = $nodes[$meId] ?? null; -// ---- 转成 family-chart 需要的 Datum 格式 ---- -// ⚠️ 「我」排最前(见上面坑①)。其余按名册顺序跟上,库自己会按辈分重新摆放。 -$order = [(int)$meId]; -foreach (array_keys($people) as $pid) { - if ((int)$pid !== (int)$meId) { - $order[] = (int)$pid; +// ---- v14:先圈定「上 / 本 / 下三代里所有有关系的人」---- +// +// ⚠️ 这是本轮重写的核心(用户第 5 条:「显示不齐全…以本人为中心去搜索,只要有关系的 +// 都检索出来进行展示」)。旧写法把**全名册**塞给库,看着多,其实库只画 +// 「从 main_id 出发的层次结构」,横向的堂表亲、配偶那边的旁支照样进不来, +// 而树上又挂了一堆根本没关系的人(看着像"家谱里混进了外人")。 +// 现在先自己按**代数**筛选(focusMap),再交给库 —— 库拿到的人就是该出现的人。 +$focusGen = \App\Kinship::focusMap($graph, (int)$meId, $people, 2, 2); +if (!isset($focusGen[(int)$meId])) { + $focusGen[(int)$meId] = 0; +} +$focusIds = array_map('intval', array_keys($focusGen)); + +// ---- 树根 = 「我」往上走到的最高那一代 ---- +// +// ⚠️⚠️ 这是 v14 最关键的一处实测结论(_fb_kin_probe.py / probe3 跑出来的): +// family-chart 画的是「**树根**的祖先链 + 后代 + 树根的兄弟姐妹 + 各人的配偶」。 +// 树根 = 我 时,**伯父 / 堂兄 / 嫂子 / 侄子 一个都不画**(实测 12 人只画出 8 人)—— +// 因为堂兄是"我伯父的孩子",不在"我的后代"里,也不是"我的兄弟姐妹"。 +// 把树根换成**我的最高祖先**,整棵树就变成"这位祖先的全部后代", +// 于是上一代(伯叔姑舅姨)、这一代(堂表亲)、下一代(侄甥)**全部**落进树里 +// (实测 12 人全画出,且按辈分正确分层)。 +// 这正是用户说的「显示不齐全…以本人为中心去搜索,只要有关系的都检索出来」。 +// +// ⚠️ 代价是"我"不再是几何中心 —— 用下面的「定位到我」按钮 + 加载后自动居中补回来。 +$rootId = (int)$meId; +for ($guard = 0; $guard < 8; $guard++) { + $up = \App\Kinship::parentIds($graph, $rootId); + $next = 0; + foreach ($up as $p) { + $p = (int)$p; + if (isset($focusGen[$p]) && (int)$focusGen[$p] < (int)$focusGen[$rootId]) { + $next = $p; + break; + } } + if ($next === 0) { + break; + } + $rootId = $next; } -$datum = []; +// ---- 连边:子女一律挂在「夫妻」上(normalizeLinks 的对称补齐)---- +// 见 App\Kinship::normalizeLinks 的注释:孩子同时挂到父母双方、双方也都有这个孩子, +// 库才会把连线从**夫妻之间那条横线**的中点拉下来 —— 否则就是"从父亲一个人身上 +// 垂下来一根线",母亲那条线永远连不到孩子(用户点名的重大 bug)。 +$links = \App\Kinship::normalizeLinks($graph, $focusIds); + +// 同一代里按长幼排(长子左、次子右);长幼键取不到时退到 id,保证顺序稳定 +$rankKey = static function (int $id) use ($people): string { + return \App\Kinship::rankValueFor($people, $id); +}; + +// ---- 转成 family-chart 需要的 Datum 格式 ---- +// ⚠️ **树根**排最前(见上面坑①:`main_id = data[0].id`)。其余按「代数 → 长幼」跟上。 +$order = [$rootId]; +$rest = []; +foreach ($focusIds as $pid) { + if ((int)$pid !== $rootId) { + $rest[] = (int)$pid; + } +} +usort($rest, static function (int $a, int $b) use ($focusGen, $rankKey): int { + return ((int)$focusGen[$a] <=> (int)$focusGen[$b]) + ?: strcmp($rankKey($a), $rankKey($b)) + ?: ($a <=> $b); +}); +$order = array_merge($order, $rest); + +$onTree = array_fill_keys($focusIds, true); +$datum = []; foreach ($order as $pid) { if (!isset($people[$pid]) || !is_array($people[$pid])) { continue; } $p = $people[$pid]; - // 只连「名册里真的有的人」——树上出现一个查不到的人会画出空白卡 - $parents = []; - foreach (\App\Kinship::parents($graph, $pid) as $pp) { - if ($pp !== null && isset($people[$pp])) { - $parents[] = (string)$pp; + $mapIds = static function (array $ids) use ($onTree): array { + $out = []; + foreach ($ids as $x) { + $x = (int)$x; + if (isset($onTree[$x])) { + $out[] = (string)$x; + } } - } - $spouses = []; - foreach (\App\Kinship::spouseIds($graph, $pid) as $sp) { - if (isset($people[$sp])) { - $spouses[] = (string)$sp; - } - } - $kids = []; - foreach (\App\Kinship::childIds($graph, $pid) as $kd) { - if (isset($people[$kd])) { - $kids[] = (string)$kd; - } - } + return array_values(array_unique($out)); + }; + + $parents = $mapIds($links['parents'][$pid] ?? []); + $spouses = $mapIds($links['spouses'][$pid] ?? []); + $kids = $mapIds($links['children'][$pid] ?? []); + // 子女按长幼排:库按 rels.children 的**数组顺序**摆放(它不是自己排序的), + // 顺序错了就是"弟弟排在哥哥左边"。 + $kidsInt = array_map('intval', $kids); + usort($kidsInt, static fn(int $a, int $b): int => strcmp($rankKey($a), $rankKey($b)) ?: ($a <=> $b)); + $kids = array_map('strval', $kidsInt); // 库认 M / F / U 三种(决定卡片底色) $g = strtolower((string)($p['gender'] ?? '')); @@ -78,7 +139,7 @@ foreach ($order as $pid) { 'id' => (string)$pid, 'data' => [ 'gender' => $gender, - 'name' => (string)($p['name'] ?? ''), + 'name' => \App\Nickname::display((int)$meId, $p) ?: (string)($p['name'] ?? ''), 'label' => \App\Kinship::label($graph, (int)$meId, $pid, $people), 'avatar' => avatar_url($p['avatar'] ?? null), 'me' => $pid === (int)$meId, @@ -106,7 +167,7 @@ $canChart = count($datum) > 1;

    我的家谱

    - 以我为中心 · 同代按长幼从左到右 · 夫妻并排 · 折线表示谁是谁的父母 + 以我为中心 · 上一代 / 这一代 / 下一代 · 夫妻并排,子女从两人之间的横线连出
    @@ -123,12 +184,22 @@ $canChart = count($datum) > 1; ⚠️ 库初始化那一刻**容器必须已经有真实尺寸**:它按容器宽高算 fit 缩放, 还在 0×0 时初始化会一路缩到 0.4 倍,卡片小到看不清(实测踩过)。 */ ?> -
    +
    + + + +
    + +

    + 画的是**以我的最高祖先为根、本家血脉的整棵后代树**:父母与伯叔姑舅姨(上一代)、 + 我与兄弟姐妹和堂表亲及各自的配偶(这一代)、他们的孩子(下一代)都在上面。 「待确认」= 只填了称呼、还没对应到真实的人(虚线卡片)。 - 图里画的是**本家血脉 + 各自的配偶**;配偶的父母(如岳父母)属姻亲,不在这张图上。 - 可以直接**拖拽平移**、**滚轮缩放**;点一下卡片进对应成员页。 + 夫妻并排,**子女一律从两人之间的横线连出** —— 不管是谁登录,孩子都挂在这对夫妻上。 + 可以**拖拽平移**、**滚轮缩放**;点「回到我」把镜头挪回你自己,点卡片进对应成员页。

    @@ -173,7 +244,7 @@ $canChart = count($datum) > 1; // (抛 TypeError),异常把后面的 updateTree() 一起带走了 → // 整张家谱**一张卡都不画**(页面 200、容器有尺寸、看着"正常")。 // 用 件不依赖任何库 API,也不怕库版本变了改签名。 - return '' + '' + '' + esc(p.name) + '' @@ -207,6 +278,50 @@ $canChart = count($datum) > 1; // initial:true 才会画出第一版;fit 让整棵树自适应容器大小 chart.updateTree({ initial: true, tree_position: 'fit' }); window.kinChart = chart; + + // ---- 平移 / 居中 ---- + // 库自带拖拽与滚轮缩放,但**没有**"定位到某个人"的 API(method 清单里没有)。 + // 好在缩放是标准 d3.zoom,它把对象挂在 svg 元素上(__zoomObj / __zoom), + // 直接拿它改 transform 就行 —— 不用碰库的内部状态。 + // ⚠️ transform 的平移量是**屏幕像素**:想把某张卡挪到容器中心, + // 就是把它当前的屏幕中心与容器中心之差加到 t.x / t.y 上,k 不动。 + var svg = root.querySelector('svg.main_svg'); + + function currentTransform() { + if (!svg) return null; + return svg.__zoom || null; + } + function applyTransform(t) { + if (!svg || !svg.__zoomObj || !window.d3) return; + window.d3.select(svg).call(svg.__zoomObj.transform, t); + } + function centerOn(id) { + var el = root.querySelector('.kin-card[data-kin-id="' + id + '"]'); + if (!el || !window.d3) return; + var t = currentTransform(); + if (!t) return; + var rr = root.getBoundingClientRect(); + var er = el.getBoundingClientRect(); + var dx = (rr.left + rr.width / 2) - (er.left + er.width / 2); + var dy = (rr.top + rr.height / 2) - (er.top + er.height / 2); + applyTransform(window.d3.zoomIdentity.translate(t.x + dx, t.y + dy).scale(t.k)); + } + window.kinCenterOn = centerOn; + + var meId = root.getAttribute('data-me') || ''; + // 库是「过渡」出来的:卡片位置要等动画停了才准。等一小会儿再居中。 + if (meId) { setTimeout(function () { centerOn(meId); }, 700); } + + var btnFit = document.getElementById('kinFit'); + var btnMe = document.getElementById('kinMe'); + if (btnFit) { + btnFit.addEventListener('click', function () { + chart.updateTree({ tree_position: 'fit' }); + }); + } + if (btnMe && meId) { + btnMe.addEventListener('click', function () { centerOn(meId); }); + } })(); diff --git a/templates/layout.php b/templates/layout.php index 239cf5d..2e09573 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -4,12 +4,22 @@ // ⚠️ v11:「影院」也**不在**顶栏里了(用户明确要求)—— 影院是观影模块的一部分, // 统一从观影列表页右侧的「设置」按钮进(那里还挂着高级搜索/统计/年度报告)。 // 别再往 $navItems 里加子功能入口,顶栏只放"模块"这一级。 -$navItems = [ - 'home' => ['首页', 'home'], - 'kin' => ['家谱', 'kin'], - 'people' => ['名册', 'people'], - 'movies' => ['观影', 'movies'], -]; +// ⚠️ v14 起导航条**不再写死**:模块清单在 App\Module::LIST 里(加一个模块只改那一处), +// 显示哪些、什么顺序由**每个人自己**的 user_settings('nav_modules') 决定 +// (用户第 10 条:允许设置哪些模块出现在导航条上,拖动排序)。 +// App\Module::navFor() 内部做了两道兜底(剔除已不存在的模块、补上新装的模块), +// 这里直接拿结果用,不要再自己过滤一遍。 +$navKeys = \App\Module::navFor((int)\App\Auth::personId()); +$navItems = []; +foreach ($navKeys as $k) { + $navItems[$k] = [\App\Module::name($k), \App\Module::route($k)]; +} +// 本页属于哪个模块 → 决定主题色(用户第 11 条:每个模块一个主题色) +$moduleKey = \App\Module::ofPage((string)$pageKey); +if ($moduleKey === '') { + $moduleKey = 'home'; +} +$themeVars = \App\Module::cssVars($moduleKey, (int)\App\Auth::familyId()); // ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的, // 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。 // home.php 用 $me 画「补充亲属关系」链接,被覆盖成 true 之后 @@ -29,7 +39,12 @@ $isLoggedIn = \App\Auth::check(); - + 上换成本模块的色, + 下面所有用到品牌色的地方(按钮、链接、页头高亮…)自动跟着变。 + ⚠️ 只覆盖这三个变量,**不碰** --paper / --ink 这些纸墨底色 —— + 模块色是用来"分区"的,把整页背景也换了就不是家书了。 */ ?> +
    @@ -40,7 +55,9 @@ $isLoggedIn = \App\Auth::check();
    diff --git a/templates/movies/cinemas.php b/templates/movies/cinemas.php index 95d0f8f..5303767 100644 --- a/templates/movies/cinemas.php +++ b/templates/movies/cinemas.php @@ -18,7 +18,7 @@ $canWrite = \App\Auth::canWrite();

    影院管理

    共 家影院
    diff --git a/templates/movies/form.php b/templates/movies/form.php index 4a7f4fc..ba97e7d 100644 --- a/templates/movies/form.php +++ b/templates/movies/form.php @@ -32,10 +32,12 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));

    diff --git a/templates/movies/index.php b/templates/movies/index.php index 2f53689..aae4eeb 100644 --- a/templates/movies/index.php +++ b/templates/movies/index.php @@ -72,8 +72,12 @@ $fmtNum = static function ($v): string { ⭐ /10 - - 👥 + + ,这里只能出 —— a 套 a 会把外层链接切开。 */ ?> + + + diff --git a/templates/movies/report.php b/templates/movies/report.php index 4074a80..ce72561 100644 --- a/templates/movies/report.php +++ b/templates/movies/report.php @@ -129,9 +129,7 @@ $fmtDate = static function (?string $d): string { - - - + diff --git a/templates/movies/search.php b/templates/movies/search.php index 2ceb283..ee02508 100644 --- a/templates/movies/search.php +++ b/templates/movies/search.php @@ -97,8 +97,10 @@ 🏛️ - - 👥 + + + + diff --git a/templates/movies/show.php b/templates/movies/show.php index 1f7a97d..cffe89e 100644 --- a/templates/movies/show.php +++ b/templates/movies/show.php @@ -152,7 +152,7 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us - 返回列表 + 返回观影 @@ -218,10 +218,7 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
    - - - - + (int)$a['id']])) ?>
    diff --git a/templates/movies/stats.php b/templates/movies/stats.php index ebc2d6d..6fba8f0 100644 --- a/templates/movies/stats.php +++ b/templates/movies/stats.php @@ -73,7 +73,7 @@ $wd = static function (?string $d): string {
    - + diff --git a/templates/people/form.php b/templates/people/form.php index 4e0adf7..2066315 100644 --- a/templates/people/form.php +++ b/templates/people/form.php @@ -93,13 +93,29 @@ $ctHints = [
    - + + +
    + + + 加进来之后也能改:在名册里把他拖到那一栏就行。 +
    + diff --git a/templates/people/index.php b/templates/people/index.php index 54c9530..2382af4 100644 --- a/templates/people/index.php +++ b/templates/people/index.php @@ -39,10 +39,9 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void { diff --git a/templates/resources/index.php b/templates/resources/index.php new file mode 100644 index 0000000..af59ddd --- /dev/null +++ b/templates/resources/index.php @@ -0,0 +1,230 @@ +, + * 没 JS 也能开合),点开才看正文与附件。 + * ② **归属于谁**:每条都挂在一个具体的人名下(不是"家庭共享"), + * 所以列表上一定看得到"这是谁的",新建时必须选归属人。 + * + * @param array $rows 我能看到的资源(带 perm / owner_nick) + * @param array $people 名册(新建时选归属人、共享给别人) + * @param array $attachs resource_id => 附件列表 + * @param array $shares resource_id => 共享列表 + */ +$isEditing = !empty($editing); +$canWrite = \App\Auth::canWrite(); +?> +
    +

    资源

    + 共 条 +
    + + + + 只读账号 + +
    +
    + + + + +
    > +

    +
    + + + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + + 取消 + +
    +
    +
    + + +
    +

    资源列表

    + +

    + 0) ? '没有匹配的资源,换个条件试试。' + : '还没有资源。证件、照片、账号这些"挂在某人名下的东西"都可以记在这里。' ?> +

    + +
    + + +
    + + + + (int)$r['owner_id'], 'name' => (string)$r['owner_name'], + 'my_nickname' => (string)($r['owner_nick'] ?? ''), + 'avatar' => $r['avatar'] ?? null, 'avatar_color' => $r['avatar_color'] ?? null, + ]) ?> + + 个附件 + +
    + +

    + +

    (没有正文)

    + + + +
    + + + + + + + + + + + 下载 + +
    + + + +
    + +
    + +
    + + + +
    + 共享给 + + + + +
    + + + +
    + 嵌套会让浏览器把外层切开)。 */ ?> +
    + + + + +
    + +
    + 共享设置 +
    + + + + + +
    + +
    + + + + + +
    + +
    + + 编辑 +
    + + + +
    +
    + +
    +
    + +
    + +
    + + + + diff --git a/templates/settings/_modules.php b/templates/settings/_modules.php new file mode 100644 index 0000000..03bea95 --- /dev/null +++ b/templates/settings/_modules.php @@ -0,0 +1,170 @@ + '#rrggbb' + */ +$allKeys = \App\Module::keys(); +$onKeys = array_map('strval', (array)($navKeys ?? [])); +// 显示顺序:已勾选的按现有顺序在前,未勾选的按 LIST 顺序在后(这样"关掉再打开"位置不跳) +$ordered = array_values(array_filter($allKeys, static fn($k) => in_array($k, $onKeys, true))); +foreach ($allKeys as $k) { + if (!in_array($k, $ordered, true)) { + $ordered[] = $k; + } +} +$isAdmin = \App\Auth::isAdmin(); +?> + +
    +

    + 导航条上的模块 + 勾上才会出现在顶部,拖动可改顺序 +

    +

    + 模块越来越多,但每个人关心的不一样 —— 这里只挑你常用的几个上导航条,其余的仍然能 + 从别处进(比如观影里还能进影院管理)。关掉的模块不会丢数据,只是从顶栏收起来。 +

    + + +
    + + +
    +

    + 模块主题色 + +

    +

    + 进到哪个模块,顶栏高亮与按钮就换成那个模块的颜色 —— 一眼就知道自己在哪儿。 + +
    这是全家共用的一套配色(不然同一个「观影」在两个人屏幕上两种颜色,反而记不住), + 所以只有管理员能改。 + +

    + + +
    + + + + + + +
    + +
    + +
    + + +
    + + + + + + + +
    + +
    +
    + + +
    +
    + +
    + + diff --git a/templates/settings/me.php b/templates/settings/me.php index 950b903..92ae4a9 100644 --- a/templates/settings/me.php +++ b/templates/settings/me.php @@ -138,3 +138,10 @@ $rosterCount = $rosterCount ?? 0;

    + + + diff --git a/templates/tasks/index.php b/templates/tasks/index.php new file mode 100644 index 0000000..9a56fa1 --- /dev/null +++ b/templates/tasks/index.php @@ -0,0 +1,236 @@ +[], 'doing'=>[], 'done'=>[]] + * @param array $people 名册(选负责人 / 共享给别人) + */ +$canWrite = \App\Auth::canWrite(); +$cols = [['todo', '待办'], ['doing', '进行中'], ['done', '已完成']]; +?> +
    +

    任务

    + 共 项 +
    + + + + 只读账号 + +
    +
    + + + + + + +
    + + + + +
    + +
    + +
    +
    + + +
    +
    + + +
    + + + +
    + + + + + + + + + 0): ?> + 💬 + + 0): ?> + 📎 + +
    + 0): ?> +
    + + / +
    + +
    + + (int)$t['assignee_id'], 'name' => (string)$t['assignee_name'], + 'my_nickname' => (string)($t['assignee_nick'] ?? ''), + 'avatar' => $t['avatar'] ?? null, 'avatar_color' => $t['avatar_color'] ?? null, + ]) ?> + + + + + + + + + + + +
    +
    + + +

    这一列是空的

    + +
    +
    + +
    + + diff --git a/templates/tasks/show.php b/templates/tasks/show.php new file mode 100644 index 0000000..6501671 --- /dev/null +++ b/templates/tasks/show.php @@ -0,0 +1,307 @@ + +
    +

    + +
    + +
    + + + + + + + + + +
    + + 返回任务 +
    +
    + +
    +
    +
    状态
    + +
    截止
    +
    + +
    +
    + + +
    循环
    + + +
    负责人
    +
    $t['assignee_name'], 'my_nickname' => $t['assignee_nick'] ?? ''])) ?>
    +
    + + +
    谁建的
    +
    $t['creator_name'], 'my_nickname' => $t['creator_nick'] ?? ''])) ?>
    +
    + + +
    属于
    +
    +
    + + +
    备注
    + +
    + + +
    + 编辑这项任务 +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    + +
    + + + +
    +

    子任务 项

    + +

    还没有子任务。把一件大事拆成几件小事,进度才看得见。

    + +
      + +
    • + +
      + + + + +
      + + + + + + + + + + + +
    • + +
    + + +
    + + 加一项子任务 +
    + + + + + +
    +
    + +
    + + + +
    +

    附件

    + +

    还没有附件。

    + +
    + + + + + + + 下载 + +
    + + + +
    + +
    + +
    + + +
    + + + + +
    + +
    + + +
    +

    共享

    + +

    还没共享给别人。共享之后,任何一个人完成都算完成。

    + +
    + + + + +
    + + + + +
    + + +
    + + +
    + + + + + +
    + +
    + + +
    +

    讨论 条

    + +

    还没有留言。写 @名字 可以点名。

    + +
      + +
    • +
      + + +
      +
      + +
      + + + + +
      + +
    • + +
    + + +
    + + + +
    +
    + +
    + + +
    +
    + + + +
    +
    + diff --git a/templates/trips/_node_form.php b/templates/trips/_node_form.php new file mode 100644 index 0000000..7c68c7b --- /dev/null +++ b/templates/trips/_node_form.php @@ -0,0 +1,90 @@ + +
    + + + + + + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + +
    + + + +
    +
    + +
    +
    + +
    +
    diff --git a/templates/trips/form.php b/templates/trips/form.php new file mode 100644 index 0000000..97faa57 --- /dev/null +++ b/templates/trips/form.php @@ -0,0 +1,118 @@ + +
    +

    + +
    + +
    +
    + + + + + +
    +
    + + + +
    + +
    + + + +
    + +
    + + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +

    名册里还没有人。

    + +
    + + + +
    + +
    + +
    + + +
    + + 不换图就不用管这里;选一张新图会替换它。 +
    + + +
    +
    + +
    + + 取消 +
    +
    +
    diff --git a/templates/trips/index.php b/templates/trips/index.php new file mode 100644 index 0000000..293e797 --- /dev/null +++ b/templates/trips/index.php @@ -0,0 +1,71 @@ + +
    +

    旅程

    + 共 趟 +
    + + 记一趟旅程 + + 只读账号 + +
    +
    + + + + +
    + + +
    + + + diff --git a/templates/trips/show.php b/templates/trips/show.php new file mode 100644 index 0000000..9b8a2ec --- /dev/null +++ b/templates/trips/show.php @@ -0,0 +1,180 @@ + +
    +

    + +
    + + 编辑 +
    + + + +
    + + 返回旅程 +
    +
    + +
    +
    + + + + 🧳 + +
    +
    + +

    📍

    + + +

    + + +
    + + (int)$a['id']])) ?> + +
    + + +

    + +
    +
    + + +
    +

    照片

    + +

    还没有照片。

    + +
    + +
    + + <?= e((string)($ph['name'] ?? '')) ?> + + +
    + + + + +
    + +
    + +
    + + +
    + + + + +
    + +
    + + + +
    +

    段

    + + +

    + +
      + +
    1. + + + +
      +
      + + + + 📍 + +
      + + +
      + + +

      + + +
      + +
      + + +
      + + + + + +
      + +
      + +
      +
      + 编辑 + +
      +
      + + + + +
      +
      + +
    2. + +
    + + + +
    + + 加一段 + +
    + +
    +