Files
FamilyBook/app/Module.php
T

337 lines
14 KiB
PHP
Raw Normal View History

<?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'],
// v24 第 13 条:纪念日(含名册里每个人的生日)
'days' => ['纪念日', '#B4633C', 'days'],
// v24 第 15 条:财务(账本 + 账目 + 共享)
'ledger' => ['财务', '#5E7A3C', 'ledger'],
// v24 第 14 批:读书(书架 + 上传解析 + 在线读 + 笔记 + 统计)
'books' => ['读书', '#6B5B95', 'books'],
];
/**
* 默认出现在导航条上的(没自定义过就用它)。首页永远在,且不能被拖到最后面也不影响。
*
* ⚠️ v24(用户 2026-10-02 第 14 条):**家谱默认不上导航条** ——
* "毕竟,不需要每天都查看家谱"。家谱模块本身还在(个人设置里能勾回来),
* 只是不再占顶栏的位置。
*/
public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources',
'tasks', 'days', 'ledger', 'books'];
/** 已经做过「家谱撤出导航条」这件事的人,记一个标记(只做一次,之后用户勾回来就尊重他) */
private const NAV_KIN_OFF_FLAG = 'nav_kin_off_v24';
/**
* 主题色的预设色板(个人设置里点一下就换)。
*
* ⚠️ 一律**中低饱和、中低明度**: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;
}
}
// v24 一次性迁移:老用户存过配置、里面还留着家谱的,帮他撤下来。
// ⚠️ 只做一次(记 NAV_KIN_OFF_FLAG)—— 之后他自己在设置里勾回来就该尊重他,
// 否则每次请求都删一遍 = "家谱开关"永远失效。
if ($personId > 0
&& (string)UserSetting::get($personId, self::NAV_KIN_OFF_FLAG, '') === '') {
UserSetting::set($personId, self::NAV_KIN_OFF_FLAG, '1');
if (in_array('kin', $saved, true)) {
$saved = array_values(array_filter($saved, static fn($k) => $k !== 'kin'));
UserSetting::set($personId, 'nav_modules',
json_encode($saved, JSON_UNESCAPED_UNICODE));
}
}
// ⚠️ 「新装的模块要自动出现」**不能**用"不在 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)));
}
}