[导航条上的名字, 默认主题色, 路由] * * ⚠️ 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 第 24 批 用户第 4 条:个人导航(从 SimpleNav 搬进来,放在**读书后面**) // 用户原话:「增加一下个人导航的这个模块就放在读书的后面,每个用户都可以 // 添加自己的导航,所有的功能模块就完全照搬原来的导航的那个系统」。 'nav' => ['导航', '#3E7A9E', 'nav'], // v27 用户 2026-10-07:个人笔记(参考 flomo / Obsidian / 思源,轻量分块 + 标签)。 // 放在**导航后面**(末尾)—— 它是"随手记",不该抢日常入口的位置。 'notes' => ['笔记', '#7A6A4F', 'notes'], ]; /** * 默认出现在导航条上的(没自定义过就用它)。首页永远在,且不能被拖到最后面也不影响。 * * ⚠️ v24(用户 2026-10-02 第 14 条):**家谱默认不上导航条** —— * "毕竟,不需要每天都查看家谱"。家谱模块本身还在(个人设置里能勾回来), * 只是不再占顶栏的位置。 */ public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources', 'tasks', 'days', 'ledger', 'books', 'nav', 'notes']; /** 已经做过「家谱撤出导航条」这件事的人,记一个标记(只做一次,之后用户勾回来就尊重他) */ 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))); } }