v14: 观影/家谱/日程/旅程/资源/任务六模块统一,首页改 dashboard

用户 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 个后顶栏撑破视口)
This commit is contained in:
2026-10-02 09:28:07 +08:00
parent 7f00d3e553
commit 71a7ec35a0
42 changed files with 6770 additions and 201 deletions
+171
View File
@@ -0,0 +1,171 @@
<?php
/**
* 统一附件(v12):旅程照片 / 资源附件 / 任务附件 / 评论附件,全都走这一张表。
*
* 为什么要统一:这几个模块对附件的要求其实是同一件事 ——
* "一个宿主挂多个文件、能下载、图片能预览、删宿主时别留下孤儿"。
* 各写一张表就要把"下载/预览/权限"写四遍,将来再加模块还得写第五遍。
*
* 下载地址**不落库**:一律由 media.php?k=<key> 现算(地址形态换过一次,
* 历史数据自动生效、零迁移 —— 这是 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();
}
}
+17
View File
@@ -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;
}
+292
View File
@@ -0,0 +1,292 @@
<?php
/**
* 头像主色 —— 给「人」一个稳定的识别色。
*
* 由来(用户 2026-10-01 的第 2 条反馈):观影人标签一律是灰底,十几个人排在一起
* 分不清谁是谁。既然每个人都有头像,就从头像里算出一种主色当背景色;
* 没有头像(或头像是纯灰)的人,按姓名算一个**稳定的**颜色兜底 ——
* 同一个人在任何页面、任何时候看到的颜色都一样。
*
* 三条刻意的设计:
* · **算一次、存库(persons.avatar_color),不要每次渲染都算**。
* 头像在 OSS 上,每渲染一个标签就下载一次原图 = 页面上 20 个人就是 20 次外网请求。
* 只在「上传/换头像」的那一刻算,之后就是读一列 CHAR(7) —— 成本趋零。
* · **主色不等于背景色**。头像是深色时主色也深,直接当背景会变成黑底黑字。
* 这里一律输出两个派生色:`bg()` 是"往白里兑"的浅色底(保证深色字读得清),
* `fg()` 是"往黑里压"的深色字(保证在浅底上够对比)。主色本身只作为**身份色**存库。
* · **灰片回退到姓名色**。证件照、黑白老照片算不出色相(饱和度极低),
* 硬取会得到一个飘忽不定的"最深的那个灰",每次换图颜色就跳一次 ——
* 不如干脆交给姓名哈希,稳定且可预期。
*/
declare(strict_types=1);
namespace App;
final class AvatarColor
{
/** 判定「这像素算不算有颜色」的色度门槛(max-min)。低于它当成灰。 */
private const CHROMA_MIN = 18;
/**
* 从一张 GD 图像里取主色,返回 '#rrggbb'。
* 取不到(纯灰/纯白/图坏)返回 '' —— 调用方再决定回退。
*
* 做法:缩到 64×64 采样 → 按色相分 36 个桶(每桶 10°)→ 以「色度 × 像素数」加权
* 选最重的桶 → 桶内按色度加权平均 RGB。比"整图取平均"稳得多:
* 整图平均会被大面积的背景(白墙)拖成一片灰,而这里白墙根本进不了桶。
*/
public static function fromGd($im): string
{
if (!$im) {
return '';
}
$w = imagesx($im);
$h = imagesy($im);
if ($w < 1 || $h < 1) {
return '';
}
// 先缩到 64×64:采样点数固定,一张 4000×3000 的原图也不会算很久
$small = $im;
if ($w > 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)));
}
}
+404
View File
@@ -0,0 +1,404 @@
<?php
/**
* 日历 / 日程(v14 新模块,导航条上叫「日程」)。
*
* 三条核心设计(用户 2026-10-01 第 7 条逐条对应):
* ① **每人可建日历本**(自定义名字 + 自定义颜色),并能共享给别人(只读 / 可写)。
* ② **观影与旅行是每人默认的两本**,名字就叫「观影」「旅行」,内容**从 events 派生** ——
* 不复制一份进 calendar_events,否则"改了观影记录、日历还是旧的"。
* 反过来,在日历里给观影本新增日程 = 写一条 events(kind='movie')(真的成为一条观影记录)。
* ③ **没有具体时分的日程 = 全天日程**(all_day=1)。
*
* 日历其实是"综合视图":自定义日程 + 观影 + 旅行 + 有截止时间的任务,
* 各模块各自出数据,日历只负责按日期摆出来(见 Calendar::month())。
*/
declare(strict_types=1);
namespace App;
final class Calendar
{
/** 系统预置的两本默认日历(kind 与名字都写死,用户的自定义本一律 kind='custom') */
public const DEFAULT_KINDS = [
'movie' => ['观影', '#7A4E8C'],
'trip' => ['旅行', '#3F7D5A'],
];
/**
* 建本时的预设色板(与影院管理同一个交互:取色器 + 点一下就换)。
*
* ⚠️ 一律挑**中低饱和**的:月网格里日程条是**白字压在色块上**,
* 太浅看不清字,太艳会盖过整个月视图。
*/
public const PRESET_CAL_COLORS = [
'#4A7C82', '#7A4E8C', '#3F7D5A', '#B0763A', '#5566A8',
'#A8574F', '#8A6B4B', '#5C8A48', '#9A5C7A', '#3D6B8E',
'#8C7A3F', '#6B5B95',
];
/** 新建一本 */
public static function create(int $familyId, int $ownerId, string $name, string $color, string $kind = 'custom'): int
{
$db = Database::get();
$db->prepare(
'INSERT INTO calendars (family_id, owner_id, name, color, kind, position) VALUES (?,?,?,?,?,?)'
)->execute([
$familyId, $ownerId, mb_substr(trim($name), 0, 50, 'UTF-8'),
// ⚠️ 统一**大写** hex:normalize() 会小写化,而我读回来的颜色要跟
// 常量里的 '#7A4E8C' 之类直接比字符串(日历本颜色是给页面用的,
// 大小写混着存会让"颜色一样吗"这种判断时对时错)。
strtoupper(AvatarColor::normalize($color) ?: '#4A7C82'),
$kind === 'movie' || $kind === 'trip' ? $kind : 'custom',
self::nextPosition($ownerId),
]);
return (int)$db->lastInsertId();
}
public static function update(int $id, string $name, string $color): void
{
Database::get()->prepare('UPDATE calendars SET name = ?, color = ? WHERE id = ?')
->execute([
mb_substr(trim($name), 0, 50, 'UTF-8'),
strtoupper(AvatarColor::normalize($color) ?: '#4A7C82'),
$id,
]);
}
/** 删一本(默认本不许删 —— 删了观影/旅行就再也回不来了) */
public static function delete(int $id): void
{
Database::get()->prepare("DELETE FROM calendars WHERE id = ? AND kind = 'custom'")->execute([$id]);
}
public static function find(int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM calendars WHERE id = ?');
$st->execute([$id]);
$r = $st->fetch();
return $r ?: null;
}
/**
* 我能看到的日历本 = 我自己的 + 别人共享给我的(带上我对它的权限)。
*
* `perm` 的含义:'owner'(我的,想怎么改都行)/ 'write' / 'read'。
*/
public static function mine(int $familyId, int $personId): array
{
// ⚠️ 共享给我的本子也要带上**主人是谁**(页面要显示"谁共享给我的"),
// 而且按"我看他叫什么"显示(owner_nick)—— 用户第 3 条。
$st = Database::get()->prepare(
"SELECT c.*, 'owner' AS perm, 0 AS shared,
p.name AS owner_name, p.avatar AS owner_avatar, p.avatar_color AS owner_color,
n.nickname AS owner_nick
FROM calendars c
JOIN persons p ON p.id = c.owner_id
LEFT JOIN nicknames n ON n.target_id = c.owner_id AND n.viewer_id = ?
WHERE c.family_id = ? AND c.owner_id = ?
UNION ALL
SELECT c.*, s.perm AS perm, 1 AS shared,
p.name AS owner_name, p.avatar AS owner_avatar, p.avatar_color AS owner_color,
n.nickname AS owner_nick
FROM calendar_shares s
JOIN calendars c ON c.id = s.calendar_id
JOIN persons p ON p.id = c.owner_id
LEFT JOIN nicknames n ON n.target_id = c.owner_id AND n.viewer_id = ?
WHERE s.person_id = ? AND c.family_id = ?
ORDER BY kind = 'custom', position, id"
);
$st->execute([$personId, $familyId, $personId, $personId, $personId, $familyId]);
return $st->fetchAll();
}
/** 这本日历我能写吗(owner 或共享为 write) */
public static function canWrite(array $cal, int $personId): bool
{
if ((int)$cal['owner_id'] === $personId) {
return true;
}
return ($cal['perm'] ?? '') === 'write';
}
/** 给某人补建两本默认日历(幂等) */
public static function seedDefaults(int $familyId, int $personId): void
{
foreach (self::DEFAULT_KINDS as $kind => list($name, $color)) {
$st = Database::get()->prepare(
'SELECT id FROM calendars WHERE owner_id = ? AND kind = ? AND name = ?'
);
$st->execute([$personId, $kind, $name]);
if ($st->fetchColumn() === false) {
self::create($familyId, $personId, $name, $color, $kind);
}
}
}
/** 升级 / 安装时给全家每个账号补一遍(老库也要有这两本) */
public static function seedDefaultsForFamily(int $familyId): void
{
$st = Database::get()->prepare(
"SELECT p.id FROM persons p JOIN accounts a ON a.person_id = p.id
WHERE p.family_id = ? AND a.status = 'active'"
);
$st->execute([$familyId]);
foreach ($st->fetchAll() as $r) {
self::seedDefaults($familyId, (int)$r['id']);
}
}
// ---------------- 共享 ----------------
public static function shares(int $calendarId): array
{
$st = Database::get()->prepare(
'SELECT s.person_id, s.perm, p.name, p.avatar, p.avatar_color
FROM calendar_shares s JOIN persons p ON p.id = s.person_id
WHERE s.calendar_id = ? ORDER BY p.id'
);
$st->execute([$calendarId]);
return $st->fetchAll();
}
public static function setShare(int $calendarId, int $personId, string $perm): void
{
Database::get()->prepare(
'INSERT INTO calendar_shares (calendar_id, person_id, perm) VALUES (?,?,?)
ON DUPLICATE KEY UPDATE perm = VALUES(perm)'
)->execute([$calendarId, $personId, $perm === 'write' ? 'write' : 'read']);
}
public static function dropShare(int $calendarId, int $personId): void
{
Database::get()->prepare('DELETE FROM calendar_shares WHERE calendar_id = ? AND person_id = ?')
->execute([$calendarId, $personId]);
}
// ---------------- 日程 ----------------
public static function addEvent(int $calendarId, array $d): int
{
$db = Database::get();
$db->prepare(
'INSERT INTO calendar_events
(calendar_id, title, starts_at, ends_at, start_time, end_time, all_day, note, created_by)
VALUES (?,?,?,?,?,?,?,?,?)'
)->execute([
$calendarId,
mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'),
$d['starts_at'],
$d['ends_at'] ?: null,
$d['start_time'] ?: null,
$d['end_time'] ?: null,
!empty($d['all_day']) ? 1 : 0,
self::textOrNull($d['note'] ?? ''),
$d['created_by'] ?: null,
]);
return (int)$db->lastInsertId();
}
public static function updateEvent(int $id, array $d): void
{
Database::get()->prepare(
'UPDATE calendar_events SET title=?, starts_at=?, ends_at=?, start_time=?, end_time=?,
all_day=?, note=? WHERE id=?'
)->execute([
mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'),
$d['starts_at'],
$d['ends_at'] ?: null,
$d['start_time'] ?: null,
$d['end_time'] ?: null,
!empty($d['all_day']) ? 1 : 0,
self::textOrNull($d['note'] ?? ''),
$id,
]);
}
public static function deleteEvent(int $id): void
{
Database::get()->prepare('DELETE FROM calendar_events WHERE id = ?')->execute([$id]);
}
/**
* 某个月(或某段日期)里**全部**日程:
* 自定义日程(calendar_events)+ 观影(events kind=movie)+ 旅行(events kind=trip)
* + 有截止时间的任务(tasks.due_at)。
*
* 统一成一种形状返回,模板不必分辨来源:
* ['cal'=>日历本id, 'color'=>本子颜色, 'title', 'date'=>'Y-m-d', 'time'=>'HH:MM'|'',
* 'all_day'=>bool, 'kind'=>'custom|movie|trip|task', 'ref_id'=>宿主id, 'href'=>点进去的地址]
*
* ⚠️ 观影/旅行只显示**本家庭**的记录(与观影模块同一个口径:能登录就能看)。
*/
public static function month(int $familyId, array $calendars, int $personId, string $from, string $to): array
{
$out = [];
$byKind = [];
foreach ($calendars as $c) {
$byKind[(string)$c['kind']][] = $c;
}
// ---- 自定义日程 ----
$ids = [];
foreach ($calendars as $c) {
if ((string)$c['kind'] === 'custom') {
$ids[] = (int)$c['id'];
}
}
if ($ids) {
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT * FROM calendar_events
WHERE calendar_id IN ($in) AND starts_at <= ? AND COALESCE(ends_at, starts_at) >= ?
ORDER BY starts_at, start_time"
);
$st->execute(array_merge($ids, [$to, $from]));
$calById = [];
foreach ($calendars as $c) {
$calById[(int)$c['id']] = $c;
}
foreach ($st->fetchAll() as $e) {
$c = $calById[(int)$e['calendar_id']] ?? null;
$end = $e['ends_at'] ?: $e['starts_at'];
// 跨天日程:在范围内**每天**都出一条(群晖日历就是这么画的)
foreach (self::eachDay((string)$e['starts_at'], (string)$end, $from, $to) as $day) {
$out[] = [
'cal' => (int)$e['calendar_id'],
'color' => (string)($c['color'] ?? '#4A7C82'),
'calname' => (string)($c['name'] ?? ''),
'title' => (string)$e['title'],
'date' => $day,
'time' => empty($e['all_day']) ? substr((string)$e['start_time'], 0, 5) : '',
'all_day' => !empty($e['all_day']),
'kind' => 'custom',
'ref_id' => (int)$e['id'],
'href' => url('calendar/event', ['id' => (int)$e['id']]),
];
}
}
}
// ---- 观影(events kind=movie)----
if (isset($byKind['movie'])) {
$c = $byKind['movie'][0];
$st = Database::get()->prepare(
"SELECT id, title, starts_at, ends_at FROM events
WHERE family_id = ? AND kind = 'movie' AND starts_at IS NOT NULL
AND DATE(starts_at) BETWEEN ? AND ?
ORDER BY starts_at"
);
$st->execute([$familyId, $from, $to]);
foreach ($st->fetchAll() as $e) {
$day = substr((string)$e['starts_at'], 0, 10);
$t = substr((string)$e['starts_at'], 11, 5);
$out[] = [
'cal' => (int)$c['id'],
'color' => (string)$c['color'],
'calname' => (string)$c['name'],
'title' => '🎬 ' . (string)$e['title'],
'date' => $day,
'time' => $t === '00:00' ? '' : $t,
'all_day' => $t === '00:00',
'kind' => 'movie',
'ref_id' => (int)$e['id'],
'href' => url('movies/show', ['id' => (int)$e['id']]),
];
}
}
// ---- 旅行(events kind=trip)----
if (isset($byKind['trip'])) {
$c = $byKind['trip'][0];
$st = Database::get()->prepare(
"SELECT id, title, starts_at, ends_at FROM events
WHERE family_id = ? AND kind = 'trip' AND starts_at IS NOT NULL
AND DATE(starts_at) <= ? AND COALESCE(DATE(ends_at), DATE(starts_at)) >= ?
ORDER BY starts_at"
);
$st->execute([$familyId, $to, $from]);
foreach ($st->fetchAll() as $e) {
foreach (self::eachDay(substr((string)$e['starts_at'], 0, 10),
substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10),
$from, $to) as $day) {
$out[] = [
'cal' => (int)$c['id'],
'color' => (string)$c['color'],
'calname' => (string)$c['name'],
'title' => '🧳 ' . (string)$e['title'],
'date' => $day,
'time' => '',
'all_day' => true,
'kind' => 'trip',
'ref_id' => (int)$e['id'],
'href' => url('trips/show', ['id' => (int)$e['id']]),
];
}
}
}
// ---- 有截止时间的任务 ----
$st = Database::get()->prepare(
"SELECT t.id, t.title, t.due_at, t.status
FROM tasks t
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
WHERE t.family_id = ? AND t.due_at IS NOT NULL AND t.status <> 'done'
AND DATE(t.due_at) BETWEEN ? AND ?
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)
ORDER BY t.due_at"
);
$st->execute([$personId, $familyId, $from, $to, $personId, $personId]);
foreach ($st->fetchAll() as $t) {
$day = substr((string)$t['due_at'], 0, 10);
$tm = substr((string)$t['due_at'], 11, 5);
$out[] = [
'cal' => 0,
'color' => '#A8574F',
'calname' => '任务',
'title' => '☑ ' . (string)$t['title'],
'date' => $day,
'time' => $tm === '00:00' ? '' : $tm,
'all_day' => $tm === '00:00',
'kind' => 'task',
'ref_id' => (int)$t['id'],
'href' => url('tasks/show', ['id' => (int)$t['id']]),
];
}
usort($out, static function ($a, $b) {
return strcmp($a['date'], $b['date'])
?: strcmp((string)$a['time'], (string)$b['time'])
?: strcmp($a['title'], $b['title']);
});
return $out;
}
/** 把 [start, end] 夹到 [from, to] 里,逐天列出(含端点) */
private static function eachDay(string $start, string $end, string $from, string $to): array
{
$s = max($start, $from);
$e = min($end, $to);
if ($s > $e) {
return [];
}
$out = [];
$cur = $s;
for ($i = 0; $i < 400 && $cur <= $e; $i++) {
$out[] = $cur;
$cur = date('Y-m-d', strtotime($cur . ' +1 day'));
}
return $out;
}
private static function nextPosition(int $ownerId): int
{
$st = Database::get()->prepare('SELECT COALESCE(MAX(position),0)+10 FROM calendars WHERE owner_id = ?');
$st->execute([$ownerId]);
return (int)$st->fetchColumn();
}
private static function textOrNull(string $v): ?string
{
$v = trim($v);
return $v !== '' ? $v : null;
}
}
+140 -5
View File
@@ -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<int,int> 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<int,int> $ids 图上会出现的人(过滤悬空引用用)
* @return array{parents:array<int,int[]>,children:array<int,int[]>,spouses:array<int,int[]>}
*/
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 为中心,向上向下各展开若干代,产出可渲染的家谱树。
*
+307
View File
@@ -0,0 +1,307 @@
<?php
/**
* 模块注册中心 —— 「有哪些模块 / 谁在导航条上 / 各自什么主题色」。
*
* 为什么要有这么一张表(用户 2026-10-01 第 10、11 条):
* · 模块越来越多(观影 / 旅程 / 日程 / 资源 / 任务…),导航条不可能无限加;
* 每个人关心的不一样,得让他自己挑「哪几个出现在导航条上」并拖动排序。
* · 每个模块一个主题色,进到哪个模块一眼就认出来。
*
* 三条分工(别混):
* · **模块本身**(有哪些、叫什么、默认什么色)→ 这里的 LIST,写死在代码里;
* · **谁在导航条上 / 什么顺序** → user_settings('nav_modules'),**每人一份**;
* · **主题色** → settings('module_colors'),**家庭级**(一个家认同一套配色,
* 不然同一个"观影"在爸爸那屏是紫的、妈妈那屏是绿的,反而记不住)。
*
* ⚠️ 新增模块的唯一入口就是 LIST:加一行,导航条、主题色、个人设置里的开关
* 全部自动出现,不用再去改 layout.php 之类的地方(以前就是漏改一处 →
* 「有页面但导航条上找不到入口」)。
*/
declare(strict_types=1);
namespace App;
final class Module
{
/**
* key => [导航条上的名字, 默认主题色, 路由]
*
* ⚠️ 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)));
}
}
+53 -12
View File
@@ -24,16 +24,17 @@ namespace App;
final class Movie
{
/** @return array<int,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<int,array{id:int,name:string,avatar:?string}> 同行的人 */
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();
}
+11 -8
View File
@@ -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);
+17
View File
@@ -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]);
+202
View File
@@ -0,0 +1,202 @@
<?php
/**
* 资源(v12,用户 2026-10-01 第 8 条):挂在**某个人名下**的附属信息。
*
* 形态:标题 + 一段文字 + 多个附件(证件扫描件、一寸照片、合同…)。
* 访问:名下的人自己当然能看能改;另外可以**共享给多人**,各设只读 / 读写。
*
* 两条关键设计:
* ① **共享表里不含 owner**(`resource_shares` 只记"额外被共享的人")——
* 不然同一个人在两张表里各有一条,权限要取并集,改一处忘一处就会
* "本人反而看不了自己的东西"。
* ② **默认折叠**:一屏列出几十条正文会很长,列表只露标题行,点开才看正文与附件。
* 折叠用原生 <details>,没有 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;
}
}
+328 -5
View File
@@ -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(
+654
View File
@@ -0,0 +1,654 @@
<?php
/**
* 任务(v12,用户 2026-10-01 第 9 条):看板 + 两级子任务 + 评论@人 + 共享 + 循环。
*
* 看板三列直接落在 `status` 上(todo / doing / done),不另存"列"字段 ——
* "列"和"状态"本来就是一件事,存两份必然对不上(拖到 done 了状态还是 doing 之类)。
*
* 两级:只做父任务 / 子任务两层(parent_id)。**不做无限层级** ——
* 家庭任务("周末大扫除"下面挂"擦玻璃""洗车")两层足够,
* 无限层级要递归查询 + 缩进渲染,代价远大于收益。
* ⚠️ 子任务的 status 独立于父任务:父任务可以还在 doing 而某个子任务已经 done,
* 这正好是"还剩几件没做"的进度条来源。
*
* 循环:标为 done 时,如果 repeat_rule 非空,就**新生成一条**下个周期的任务,
* 原任务保留在 done 列里(历史可回看)。不原地改 due_at ——
* 那样"上次是什么时候做的"就永远消失了。
*/
declare(strict_types=1);
namespace App;
final class Task
{
public const STATUS = ['todo' => '待办', '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<int,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> 再转义会把标签变成 &lt;b&gt;,
* 页面上一堆字面的尖括号)。匹配按"长的名字优先",
* 否则「@张三丰」里的「@张三」会先被短名字命中,剩下「丰」没被高亮。
*
* @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] = '<b class="mention">@' . e($nm) . '</b>';
}
}
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;
}
}
+405
View File
@@ -0,0 +1,405 @@
<?php
/**
* 旅程(v12,从 TripLog 迁入;导航条上叫「旅程」)。
*
* 与观影一样挂在**统一事件原语**上:
* events(kind='trip') —— 骨架:标题 / 起止时刻 / 地点(目的地)/ 备注
* trip_details —— 卫星表:封面图 + 一段话小结
* event_attendees —— 同行的人
* attachments(target=trip) —— 照片与附件
* trip_nodes —— 行程节点:这趟旅程里的一段一段(计划 / 实际走过的双轨)
*
* 为什么不把 TripLog 的 activities/trip_nodes 原样搬过来:
* TripLog 的 activities 与家书的 events 是同一个东西(一次出行 = 一个有时间、有人、
* 要上日历的事件)。再建一张 activities 就会出现"两条时间线",日历要同时读两张表,
* 迁移后还得保证两边同步 —— 那正是统一事件原语要消灭的重复。
*
* 计划/执行双轨(TripLog 最有价值的一点)保留了:
* 出发前排的行程是 plan,真正走完记的是 reality,两轨并存,
* 于是"我们本来打算去哪、后来实际上去了哪"都能回看。
*/
declare(strict_types=1);
namespace App;
final class Trip
{
/** 节点类型(TripLog 的 node_types 简化成固定几种 —— 家庭出游用不到那么多) */
public const NODE_TYPES = [
'play' => ['游玩', '🎡'],
'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;
}
}
+9 -3
View File
@@ -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
{
+72
View File
@@ -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` 为空时输出 `<span>`:瀑布流里整张卡片已经是 `<a>` 了,
* `<a>` 套 `<a>` 是非法 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 = '<img class="chip-av" src="' . e(media_url((string)$p['avatar'])) . '" alt="">';
} else {
$av = '<span class="chip-av chip-av-text">'
. e(mb_substr($name, 0, 1, 'UTF-8')) . '</span>';
}
$cls = 'viewer-tag' . ($extraClass !== '' ? ' ' . $extraClass : '');
$inner = $av . '<span class="chip-name">' . e($name) . '</span>';
if ($href !== '') {
return '<a class="' . e($cls) . '" style="' . e($style) . '" href="' . e($href) . '">'
. $inner . '</a>';
}
return '<span class="' . e($cls) . '" style="' . e($style) . '">' . $inner . '</span>';
}
/**
* 读「日期 + 24 小时制时:分:秒」组合控件(templates/_datetime.php 产出)。
*