From 71a7ec35a0176951d695dc260b8b9de6f4996fc2 Mon Sep 17 00:00:00 2001
From: laoyang
Date: Fri, 2 Oct 2026 09:28:07 +0800
Subject: [PATCH] =?UTF-8?q?v14:=20=E8=A7=82=E5=BD=B1/=E5=AE=B6=E8=B0=B1/?=
=?UTF-8?q?=E6=97=A5=E7=A8=8B/=E6=97=85=E7=A8=8B/=E8=B5=84=E6=BA=90/?=
=?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=85=AD=E6=A8=A1=E5=9D=97=E7=BB=9F=E4=B8=80?=
=?UTF-8?q?=EF=BC=8C=E9=A6=96=E9=A1=B5=E6=94=B9=20dashboard?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
用户 14 条需求一次交付(1-13 功能 + 14 工作方式)。
观影(1-4)
- 返回文案全模块统一为「返回观影」(不再有「返回列表」「返回观影记录」三种叫法)
- 观影人一律用彩色人物标签 person_chip:底色由头像主色算出来(上传时存库),
瀑布流卡片也按人显示;五处观影模板全部改用它
- 人员显示统一走 person_name()(shown → 我给他起的称呼 → 姓名)
- 影院管理页与年度报告页的上下两块用 .card + .card 统一给间距(原来紧贴成一整块)
家谱(5)
- 树根从「我」换成**辈分最高的祖先**:库画的是"树根的祖先链+后代+树根的兄弟姐妹",
根=我 时伯父/堂兄/嫂子/侄子一个都不画(12 人夹具只画出 8 人)
- focusMap 按**代数**圈定三代(不再按步数),normalizeLinks 把子女对称挂到父母双方
—— 修掉"母亲登录看不到孩子"
- 新增「看全部 / 回到我」两个按钮,放在画布外(画布整块被库接管了手势)
统一事件原语(6-9)
- 旅程:TripLog 全量迁入(3 趟旅程 / 21 段行程 / 72 张照片 / 5 个人)。
复用 events(kind='trip') + trip_details + trip_nodes(计划/执行双轨保留)
- 日程:每人可建日历本(自定义色)+ 共享只读/可写;观影与旅行是每人默认两本,
内容是**从各模块实时派生**的(不复制,所以改了观影日期日历立刻跟着变);
没有具体时分 = 全天;月视图参照群晖 Calendar
- 资源:挂在某人名下的附属信息(标题+正文+多附件),可共享给多人并设只读/读写,
默认折叠、可搜标题与正文
- 任务:看板三列 + 两级子任务 + 评论@人 + 共享 + 循环(完成时按原截止日推下一期)
- 统一附件表:四个模块的附件共用一张,下载地址不落库、一律由 media.php 现算
模块化(10-11)
- App\Module 是模块的唯一来源(加一行 = 导航条/主题色/个人设置三处自动出现)
- 个人设置里可勾选要显示哪些模块、拖动排序(关掉的模块**不会**被兜底补回来)
- 每个模块一套主题色(家庭级),只覆盖 --brand 三兄弟,不碰纸墨底色
其他(12-13)
- 新增成员表单可选所属分组(编辑表单没有:分组是"我和他的关系",只归我自己)
- 首页改 dashboard:接下来的日子 / 未来七天 / 手头在做 / 往年今日。
家人卡片从首页撤走(看人去名册)
验证(全部通过)
- e2e 542 条(新增 4 模块端到端 60+ 条)
- 桌面几何 368 / 移动端几何 520 / 家谱 12 / 交互 97 / 老库升级 123
- SQL 列名静态校验 0 处可疑(对着真实表结构)
- 反向验证 18 条新注入全部抓住
顺带修掉的既有问题
- 老库升级回填 SQL 按实际存在的列动态拼(缺列不再 1054 炸断升级)
- 全新安装时 Schema::ensure 跑在"人还没建"之前 → 补建日历本三处齐备
- 窄屏导航横向溢出(模块从 4 个加到 8 个后顶栏撑破视口)
---
app/Attachment.php | 171 +++++
app/Auth.php | 17 +
app/AvatarColor.php | 292 ++++++++
app/Calendar.php | 404 ++++++++++
app/Kinship.php | 145 +++-
app/Module.php | 307 ++++++++
app/Movie.php | 65 +-
app/MovieStats.php | 19 +-
app/Person.php | 17 +
app/Resource.php | 202 +++++
app/Schema.php | 333 ++++++++-
app/Task.php | 654 ++++++++++++++++
app/Trip.php | 405 ++++++++++
app/Upload.php | 12 +-
app/helpers.php | 72 ++
assets/css/app.css | 291 ++++++-
index.php | 1207 +++++++++++++++++++++++++++++-
install.php | 6 +
templates/calendar/calendars.php | 175 +++++
templates/calendar/event.php | 84 +++
templates/calendar/index.php | 197 +++++
templates/home.php | 205 ++---
templates/kin/tree.php | 177 ++++-
templates/layout.php | 33 +-
templates/movies/cinemas.php | 2 +-
templates/movies/form.php | 6 +-
templates/movies/index.php | 8 +-
templates/movies/report.php | 4 +-
templates/movies/search.php | 6 +-
templates/movies/show.php | 7 +-
templates/movies/stats.php | 2 +-
templates/people/form.php | 30 +-
templates/people/index.php | 7 +-
templates/resources/index.php | 230 ++++++
templates/settings/_modules.php | 170 +++++
templates/settings/me.php | 7 +
templates/tasks/index.php | 236 ++++++
templates/tasks/show.php | 307 ++++++++
templates/trips/_node_form.php | 90 +++
templates/trips/form.php | 118 +++
templates/trips/index.php | 71 ++
templates/trips/show.php | 180 +++++
42 files changed, 6770 insertions(+), 201 deletions(-)
create mode 100644 app/Attachment.php
create mode 100644 app/AvatarColor.php
create mode 100644 app/Calendar.php
create mode 100644 app/Module.php
create mode 100644 app/Resource.php
create mode 100644 app/Task.php
create mode 100644 app/Trip.php
create mode 100644 templates/calendar/calendars.php
create mode 100644 templates/calendar/event.php
create mode 100644 templates/calendar/index.php
create mode 100644 templates/resources/index.php
create mode 100644 templates/settings/_modules.php
create mode 100644 templates/tasks/index.php
create mode 100644 templates/tasks/show.php
create mode 100644 templates/trips/_node_form.php
create mode 100644 templates/trips/form.php
create mode 100644 templates/trips/index.php
create mode 100644 templates/trips/show.php
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 @@
+
+
+
日历本
+
共 = count($cals) ?> 本= $shared ? ',别人共享给我 ' . count($shared) . ' 本' : '' ?>
+
+
+
+
+ 只读账号访客只能查看日历本,不能新建或修改。
+
+
+
= $isEditing ? '编辑日历本' : '新建日历本' ?>
+
+
+
+
+
+
我自己的
+
+
还没有日历本。
+
+
+
+
+ | 名字 | 颜色 | 共享给 | 操作 |
+
+
+
+
+ |
+ = e((string)$c['name']) ?>
+
+ 系统预置
+
+ |
+
+
+ = e((string)$c['color']) ?>
+ |
+
+
+
+ 不共享
+
+
+
+ = person_chip($s) ?>
+ = ($s['perm'] ?? '') === 'write' ? '可写' : '只读' ?>
+
+
+
+
+
+ 共享设置
+
+
+
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
别人共享给我的
+
+
+ | 名字 | 谁的 | 我的权限 |
+
+
+
+ | = e((string)$c['name']) ?> |
+
+ = e(person_name(['name' => $c['owner_name'] ?? '', 'my_nickname' => $c['owner_nick'] ?? ''])) ?> |
+ = ($c['perm'] ?? '') === 'write' ? '可写' : '只读' ?> |
+
+
+
+
+
+
+
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 @@
+
+
+
= e((string)$ev['title']) ?>
+
= e((string)($ev['starts_at'] ?? '')) ?>
+
+
+
+
+
+
+ - 日期
- = e((string)$ev['starts_at']) ?>
+
+ - 到
- = e((string)$ev['ends_at']) ?>
+
+ - 时间
+ - = empty($ev['all_day']) ? e(substr((string)$ev['start_time'], 0, 5) . ' – ' . substr((string)$ev['end_time'], 0, 5)) : '全天' ?>
+
+
+ - 备注
- = nl2br(e((string)$ev['note'])) ?>
+
+
+
这本日历你只有只读权限,看得到但不能改。
+
+
+ 套
+
+
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())));
+?>
+
+
日程
+
= e(date('Y 年 n 月', $ts)) ?>
+
+
+
+
+
+
+
+
+
+
+
+
新建日程
+
+
+
+
+
+
+
+
+
+
+
= (int)$cell['day'] ?>
+
+
+
+
+
+
+
+
+
+
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' => '
= (int)$allCount ?>名册总数
= (int)$relCount ?>亲属连线
+
+
-
-
+
+
接下来的日子 两个月内
+
+ 近两个月没有人过生日等到了月份,这里会自动冒出来。
+
'身份证生日', 'solar' => '阳历生日', 'lunar' => '
+
-
+
-
= $hasRel ? '我的家人' : '家庭成员' ?>
+
未来七天 日程 · 观影 · 旅行 · 到期任务
-
-
-
-
先把家人连起来
- 目前还没有任何亲属关系,所以下面显示的是整个名册。
- 到自己的资料页填上「谁是我的父母、配偶、子女」,之后这一页就只显示跟你有关的人,
- 每个人的称呼也会按你的视角来算。
-
-
-
-
-
-
-
-
-
名册还是空的
- 先把家里人加进来,之后他们就能出现在每一条记录里。
-
-
+
+ 这七天还没有安排有事先记一笔,日后再翻才有得看。
-
-
-
-
-
-
+
+
+
+
+
+
+
+ = !empty($memories['exact']) ? '往年今日' : '往年这个月' ?>
+ = e(date('n 月 j 日')) ?>
+
+
+
+
+ = !empty($memories['exact']) ? '往年的这一天还没有记录' : '往年的这个月还没有记录' ?>
+ 日子还长,等有了第一次,明年这时候就会自己冒出来。
+
+
+
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();
影院管理
共 = count($cinemas) ?> 家影院
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));
= $isEdit ? '编辑观影记录' : '记一场电影' ?>
= $isEdit ? e($m['title']) : '片名必填,其他都可以以后再补' ?>
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 {
⭐ = e($fmtNum($mv['review_avg'])) ?>/10
-
-
👥 = e($mv['attendee_names']) ?>
+
+ ,这里只能出
—— a 套 a 会把外层链接切开。 */ ?>
+
+ = person_chip($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 {
-
-
= e((string)$vw['name']) ?>
-
+ = person_chip($vw) ?>
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 @@
🏛️ = e($mv['cinema_name']) ?>
-
-
👥 = e($mv['attendee_names']) ?>
+
+
+ = person_chip($a) ?>
+
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
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 {
- = e((string)$r['name']) ?>
+ = e(person_name($r)) ?>
= (int)$r['cnt'] ?>
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();
+?>
+
+
资源
+
共 = count($rows) ?> 条
+
+
+
+
+ 只读账号
+
+
+
+
+
+
+
+ >
+
= $isEditing ? '编辑资源' : '新建资源' ?>
+
+
+
+
+
+
资源列表
+
+
+ = ($keyword !== '' || $ownerId > 0) ? '没有匹配的资源,换个条件试试。'
+ : '还没有资源。证件、照片、账号这些"挂在某人名下的东西"都可以记在这里。' ?>
+
+
+
+
+
+
+
+ = e((string)$r['title']) ?>
+
+ = person_chip([
+ 'id' => (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,
+ ]) ?>
+ = e(\App\Resource::permWord((string)$r['perm'])) ?>
+ = count($as) ?> 个附件
+
+
+
+
= nl2br(e((string)$r['content'])) ?>
+
+
(没有正文)
+
+
+
+
+
+
+
+
+ 共享给
+
+ = person_chip($s) ?>
+ = ($s['perm'] ?? '') === 'write' ? '可读写' : '只读' ?>
+
+
+
+
+
+
+ 嵌套会让浏览器把外层切开)。 */ ?>
+
+
+
+ 共享设置
+
+
+
+
+
+
+
编辑
+
+
+
+
+
+
+
+
+
+
+
+
+
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();
+?>
+
+
+
+ 导航条上的模块
+ 勾上才会出现在顶部,拖动可改顺序
+
+
+ 模块越来越多,但每个人关心的不一样 —— 这里只挑你常用的几个上导航条,其余的仍然能
+ 从别处进(比如观影里还能进影院管理)。关掉的模块不会丢数据,只是从顶栏收起来。
+
+
+
+
+
+
+
+
+ 模块主题色
+ = $isAdmin ? '全家共用一套' : '由管理员设置' ?>
+
+
+ 进到哪个模块,顶栏高亮与按钮就换成那个模块的颜色 —— 一眼就知道自己在哪儿。
+
+
这是全家共用的一套配色(不然同一个「观影」在两个人屏幕上两种颜色,反而记不住),
+ 所以只有管理员能改。
+
+
+
+
+
+
+
+
+ = e(\App\Module::name($k)) ?>
+
+
+
+
+
+
+
+
+
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', '已完成']];
+?>
+
+
任务
+
共 = (int)$total ?> 项
+
+
+
+
+ 只读账号
+
+
+
+
+
+
+
新建任务
+
+
+
+
+
+
+
+
+
+
+
+ = e($label) ?>
+ = count($board[$key] ?? []) ?>
+
+
+
+
+
+
+ = e((string)$t['title']) ?>
+
+
+
+
+ = e(\App\Task::dueWord($t['due_at'] ?? null)) ?>
+
+
+
+ = e(\App\Task::repeatWord($t['repeat_rule'])) ?>
+
+ 0): ?>
+ 💬 = (int)$t['comment_count'] ?>
+
+ 0): ?>
+ 📎 = (int)$t['attach_count'] ?>
+
+
+ 0): ?>
+
+
+ = $doneN ?>/= $total ?>
+
+
+
+
+
+
+
这一列是空的
+
+
+
+
+
+
+
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 @@
+
+
+
= e((string)$t['title']) ?>
+
= e(\App\Task::statusWord((string)$t['status'])) ?>
+
+
+
+
+
返回任务
+
+
+
+
+
+ - 状态
- = e(\App\Task::statusWord((string)$t['status'])) ?>
+
+ - 截止
+ -
+ = e((string)$t['due_at']) ?>= \App\Task::dueWord($t['due_at'] ?? null) !== '' ? '(' . e(\App\Task::dueWord($t['due_at'] ?? null)) . ')' : '' ?>
+
+
+
+
+ - 循环
- = e(\App\Task::repeatWord($t['repeat_rule'])) ?>
+
+
+ - 负责人
+ - = e(person_name(['name' => $t['assignee_name'], 'my_nickname' => $t['assignee_nick'] ?? ''])) ?>
+
+
+
+ - 谁建的
+ - = e(person_name(['name' => $t['creator_name'], 'my_nickname' => $t['creator_nick'] ?? ''])) ?>
+
+
+
+
+
+
+ - 备注
- = nl2br(e((string)$t['note'])) ?>
+
+
+
+
+
+ 编辑这项任务
+
+
+
+
+
+
+
+
+
子任务 = count($t['children']) ?> 项
+
+
还没有子任务。把一件大事拆成几件小事,进度才看得见。
+
+
+
+ -
+
+
+
+ = $c['status'] === 'done' ? '☑' : '☐' ?>
+
+
+ = e((string)$c['title']) ?>
+
+
+
+ = e(\App\Task::dueWord($c['due_at'] ?? null)) ?>
+
+
+
+
+
+
+
+
+ + 加一项子任务
+
+
+
+
+
+
+
+
+
附件
+
+
还没有附件。
+
+
+
+
+
+
+
+
+
+
+
共享
+
+
还没共享给别人。共享之后,任何一个人完成都算完成。
+
+
+
+ = person_chip($s) ?>
+ = ($s['perm'] ?? '') === 'write' ? '可写' : '只读' ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
讨论 = count($t['comments']) ?> 条
+
+
还没有留言。写 @名字 可以点名。
+
+
+
+ -
+
+ = person_chip($c) ?>
+ = e((string)$c['created_at']) ?>
+
+ = \App\Task::commentHtml((string)$c['content'], $names) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
= $isEdit ? '编辑旅程' : '记一趟旅程' ?>
+
+
+
+
+
+
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 @@
+
+
+
旅程
+
共 = (int)$total ?> 趟
+
+
+
+
+
+
+
+ = $keyword !== '' ? '没有匹配的旅程' : '还没有旅程记录' ?>
+ = $keyword !== '' ? '换个关键词试试,或清除搜索看全部。'
+ : ($canWrite ? '点右上角「记一趟旅程」,把一次出行记下来。' : '家人还没有记录旅程。') ?>
+
+
+
+
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 @@
+
+
+
= e((string)$t['title']) ?>
+
= e(\App\Trip::formatSpan($t['starts_at'] ?? null, $t['ends_at'] ?? null)) ?>
+
+
+
编辑
+
+
+
返回旅程
+
+
+
+
+
+
+
$t['cover_key'])) ?>)
+
+
🧳
+
+
+
+
+
📍 = e((string)$t['location']) ?>
+
+
+
= nl2br(e((string)$t['summary'])) ?>
+
+
+
+
+ = person_chip($a, url('people/show', ['id' => (int)$a['id']])) ?>
+
+
+
+
+
+
+
+
+
+
+
+
照片
+
+
还没有照片。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= e($catName) ?> = count($list) ?> 段
+
+
+
= $cat === 'plan' ? '还没有排计划。' : '还没有记录实际行程。' ?>
+
+
+
+ -
+
+ = e(\App\Trip::typeIcon((string)$n['node_type'])) ?>
+
+
+
+ = e((string)$n['title']) ?>
+ = e(\App\Trip::statusWord((string)$n['status'])) ?>
+
+ 📍 = e((string)$n['place']) ?>
+
+
+
+
+
= e($span) ?>
+
+
+
= nl2br(e((string)$n['detail'])) ?>
+
+
+
+ = person_chip($mm) ?>
+
+
+
+
+
+
+
+
+
+ 编辑
+
+
+
+
+
+
+
+
+
+
+
+
+ + 加一段= e($catName) ?>
+
+
+
+
+