2026-10-02 09:28:07 +08:00
|
|
|
|
<?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'],
|
2026-10-03 08:18:05 +08:00
|
|
|
|
// v24 第 13 条:纪念日(含名册里每个人的生日)
|
|
|
|
|
|
'days' => ['纪念日', '#B4633C', 'days'],
|
|
|
|
|
|
// v24 第 15 条:财务(账本 + 账目 + 共享)
|
|
|
|
|
|
'ledger' => ['财务', '#5E7A3C', 'ledger'],
|
2026-10-05 10:58:17 +08:00
|
|
|
|
// v24 第 14 批:读书(书架 + 上传解析 + 在线读 + 笔记 + 统计)
|
|
|
|
|
|
'books' => ['读书', '#6B5B95', 'books'],
|
2026-10-02 09:28:07 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 默认出现在导航条上的(没自定义过就用它)。首页永远在,且不能被拖到最后面也不影响。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ v24(用户 2026-10-02 第 14 条):**家谱默认不上导航条** ——
|
|
|
|
|
|
* "毕竟,不需要每天都查看家谱"。家谱模块本身还在(个人设置里能勾回来),
|
|
|
|
|
|
* 只是不再占顶栏的位置。
|
|
|
|
|
|
*/
|
2026-10-05 10:58:17 +08:00
|
|
|
|
public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources',
|
|
|
|
|
|
'tasks', 'days', 'ledger', 'books'];
|
2026-10-03 08:18:05 +08:00
|
|
|
|
|
|
|
|
|
|
/** 已经做过「家谱撤出导航条」这件事的人,记一个标记(只做一次,之后用户勾回来就尊重他) */
|
|
|
|
|
|
private const NAV_KIN_OFF_FLAG = 'nav_kin_off_v24';
|
2026-10-02 09:28:07 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 主题色的预设色板(个人设置里点一下就换)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 一律**中低饱和、中低明度**: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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-03 08:18:05 +08:00
|
|
|
|
|
|
|
|
|
|
// 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// ⚠️ 「新装的模块要自动出现」**不能**用"不在 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)));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|