用户原话四条,逐条落地:
1. 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
→ 顶层一律改称**活动**:按钮「记一次活动」、页标题「活动」、`N 次` / `N 段旅程`;
活动详情三组改叫「计划的旅程 / 走过的旅程 / 没成行的」;
节点表单「这段旅程叫什么」「阶段」;flash 文案 17 处同步。
(导航条仍叫「旅程」——用户就是这么称呼这个模块,列表页加副标题说清两级关系。)
2. 「活动不需要开始和结束时间,是根据活动中的旅程,最早的旅程开始时间,和最晚的
旅程结束时间,计算出来的。目的地也不需要…去的城市,也是根据旅程,得到的。」
→ 活动**不再存**这三样,全部**读时算出来**:
· Trip::derived() 最早开始 / 最晚结束 / 城市去重
· Trip::cities() 按最早出现时间排(先后顺序本身是信息,不是字母序)
· Trip::attachDerived() 列表页批量补(避免 N+1)
· Trip::activitySpan() 没有旅程时说「还没有旅程」,不用「日期待补」
create/update 不再写 events 的起止与地点(老值保留不动,页面不读)。
列表排序/搜索、首页「最近去了」改走子查询。
3. 「题图那里,没有办法上传图片,点击哪里都没反应。」——**真 bug**
「点图 = 开文件框」那句 JS 只写在 people/form.php 的内页脚本里(头像专用);
第 14 批加的旅程题图与图书封面照抄了同一套 DOM 却没有对应的 JS,
于是图块有 cursor:pointer 与 hover 高亮(看着能点),点下去毫无反应。
→ 做成声明式通用绑定:图上写 data-pick-input="#fileInputId",
app.js 统一接管(一个页面多个也各绑各的),并删掉旧的那份专用实现。
三处(头像 / 题图 / 图书封面)全挂上。
4. 「添加活动的页面,顺序是这样的:添加标题,上传并裁剪题图,然后一段简介,就可以了。」
→ 主区就这三项,顺序照原话;时间/城市不做输入项(只留一句说明它们从哪来,
不说清用户会满页找"怎么没地方填时间")。
备注 / 公开 / 同行的人收进「更多设置」折叠区——功能一个没丢。
顺带修掉两处会炸的实现问题:
· ORDER BY 引用聚合别名(first_at)在 MariaDB/MySQL 报 1247,且只在运行时炸、
php -l 看不出 → 改成 ORDER BY MIN(starts_at),否则整个旅程模块 Fatal。
· e2e 里"详情页显示了派生时间"原本写成整页查找,下面每段旅程自己也渲染日期,
于是把派生值塞成 null 也照样绿(借光假绿)→ 判据收进页头那一段。
验证:e2e 845/0(+15 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 移动几何 688/0 ·
交互 92/0 · 题图真机点击 4/0 · 读书窄屏 15/0 · 家谱 34/0 + 10/0 + 反向 3/3 ·
观影按钮 60/0 + 反向 3/3 · 本批反向验证 4/4(题图绑定抽掉→红、派生值不挂→红、
城市退回读 location→红、时间字段加回表单→红)· 部署静态断言新增两组(含"活动不许
再写起止时间/目的地"与"ORDER BY 不许引用聚合别名"的反退化防线)。
部署后服务器自检 19/19,五个关键文件 md5 与线上逐一核对一致。
337 lines
14 KiB
PHP
337 lines
14 KiB
PHP
<?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)));
|
||
}
|
||
}
|