Files
FamilyBook/app/Module.php
T
laoyang 71a7ec35a0 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 个后顶栏撑破视口)
2026-10-02 09:28:07 +08:00

308 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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)));
}
}