Files
FamilyBook/app/Module.php
T
laoyang 09e30d27d4 v24 第 24 批:选人冲掉已有成员 / 首页改版 / 个人导航 / 微信读书 / 财务图
用户 6 条原话(要点):
  ①「选择名册里面人员的那个弹出框有个问题,就是会把页面里面已有的人员给冲掉。
     比方说电影里面的观影人或者是旅程里面的参与人…这是一个 bug 应该通篇检查。」
    并按「输入拼音 → 空格选中第一个 → 输入框清空回焦 → 再输下一个 → 回车=确定」重做交互。
  ② 首页简要信息改成:问候 + 姓名 + 日期 + 星期 + 农历 + 节气/节日/纪念日/生日,
     下面「今天几条日程 / 几件待办」,天气**彩色图标**放**最右侧**,温度风力,最下面地址。
  ③「家里的镜框这里呃全部不要了」→ 删模块卡片墙 + 未来七天;日程与任务**左右各半**、
     高度固定、内部滚动且**不显滚动条**;日程**掐头去尾** + 当前时刻**红线**;
     任务显示标题+状态+**所在看板名**;最下面往年的今天。
  ④「把个人导航项目完整搬过来,放在读书后面」;免登录展示页 `/nav/?<短语>`;
     右上角给**登录**入口;展示页头像用**用户自己的头像**。
  ⑤ 读书加笔记 + 导入微信读书笔记(用户明确「**用微信读书 api key**」)。
  ⑥ 财务页第二张多线图不随类别标签筛选。

用户拍板(AskUserQuestion):① 首页删「模块卡片墙 + 未来七天」两块;
② 微信读书用官方 API Key;③ 导航短语「自己起名 + 可一键换随机码」都做;
④ 6 条一次做完再上线。

════ 一、 🔴 第 1 条:复现出的真 bug(全站性)════
**根因**:选人清单是**打开弹框时才建 DOM**(懒建,为性能),而承载"已选"的
checkbox 就在那份清单里 → **没打开过弹框就提交**时表单里一个值都没有 →
后端把"表单里没有 attendees"理解成"用户要把人全删掉" → **静默清空**。
实测复现:库里同行人 `2,3,4` → 只改标题保存后变成 **NULL**。

修法:模板加**承运字段**(`.pp-carry` 隐藏 input,承载当前已选);
JS 建清单时**撤掉**它(否则同一个 name 会带两份值)。
⚠️ 裸 POST(完全不带 attendees)**本来就该清空** —— 那是显式地"不选任何人",
所以断言也必须照着"浏览器实际提交的样子"(带承运字段)来测。

**键盘流程**(用户第 1 条后半):空格 = 选**当前可见列表里第一个还没选的**
(⚠️ 必须跳过已选中的 —— 否则会把它**取消**,与"我要选人"正好相反,本轮真机跑出来的缺陷);
选中后清空输入框 + 光标回输入框;回车 = 确定。空格必须 `preventDefault`
(否则输入框里多一个空格字符)。

覆盖范围:4 个多选(`attendees[]` / `members[]` / `shares[]` / 修订 `members[]`)
+ 全部单选(归属人 / 负责人 / 纪念日人等)。

════ 二、 首页改版(第 2、3 条)════
自上而下只有三块:
  ① 信息面板:问候(早/中/晚 + 姓名)→ 日期 / 星期 / 农历 → 日子标记
     (法定节假日 / 节气 / 传统节日 / 纪念日 / 生日,各一色)
     → 今日数字(今天几条日程、几件待办)| **最右**:彩色天气图标 + 温度 + 风力 + 地址
  ② 日程 / 任务 **左右各半**(各 360px 固定高、内部滚动、**不显滚动条**三套写法)
     日程是**日视图形式的刻度**、**掐头去尾**(首末日程前后各 1h)、**当前时刻红线**
     任务是标题 + **状态** + **看板名** + 截止
  ③ 往年的今天

新增 `Home::todayMarks()`(把五类来源合成一句话:"今天是国庆节 · 大暑")与
天气的 `tone` 色调档(彩色图标靠 CSS 上色,**不用 emoji** —— 避免旧系统豆腐块)。
删掉:`mod-cards` 模块卡片墙、`未来七天`。

════ 三、 个人导航(第 4 条,SimpleNav 完整搬运)════
`app/Nav.php`(4 张 `nav_*` 表 + 稀疏排序 + 短语)· `app/NavFetch.php`
(抓取与图标生成,27KB,从原项目 functions.php 机械搬运,**原文那批"线上实测"注释全部保留**)
· `nav_show.php`(**全站唯一免登录页**)· `nav_suggest.php`(联想代理,凭短语门禁)
· `templates/nav/index.php`(管理页:板块/站点/引擎/设置 + 拖拽排序)。

搬运只改两处(不改会坏):① 独立 SQLite → 家书的 MySQL;② **所有查询加 `person_id`**
(原项目是单用户系统,不加这一维全家人会共用同一份导航)。

免登录展示页:`/nav/?<短语>`(也认 `/nav/?<短语>` 不带参数名);
短语用户自起名(4~32 位字母数字`_-`,全局唯一、保留词拦截)或一键换随机码;
右上角**登录**入口(进家书登录页);头像用**他自己**的头像;空态给一句话。

路由三处同步(`router.php` / `.htaccess` / 部署脚本 5c **独立标记块**)——
这是本仓库第三次复用这条规律:**每加一个"站根上的独立入口",就必须是自己的标记块**。
⚠️ 线上重写规则**顺序有语义**:`^nav/suggest\.php$` 必须在 `^nav/?$` 之前
(后者会把前者吞掉)。

════ 四、 微信读书(第 5 条)════
用户提示「用微信读书 api key」是对的 —— **我的知识过时了**:2026 年官方已开放
(网关 `POST https://i.weread.qq.com/api/agent/gateway`、`Bearer wrk-` 认证、
接口 `/user/notebooks`(游标 lastSort 分页)、`/book/bookmarklist`、
`/review/list/mine`、`/book/chapterinfo`)。

`app/WeRead.php`:Key 用 `Crypto::encrypt` **加密落库**、界面只回显掩码
(官方原话:这串 Key 等价于账号凭据);导入走「拉预览 → 勾选 → 导入」,
落 `book_notes`(去重 = kind + 内容 + 位置)。
⚠️ 业务参数必须**平铺在顶层**,不能包进 `params`(官方文档专门列了这个反例)。
另一条路「手动粘贴」保留(`books/notes_import`,早已有)。

════ 五、 财务第二张图(第 6 条,**上一批我引入的 bug**)════
`g.hidden = true` 对 **SVG 的 `<g>` 无效**(它没有 `hidden` 属性)→ 线条一条都没被藏;
**而我的探针也读同一个属性** → 两边一起绿(假绿)。
改为 `style.display`(恢复用 `''` 而不是 `'block'`);探针改读
`getComputedStyle(el).display !== 'none'`(落在**渲染结果**上)。
⚠️ "探针与实现读同一个属性"是**互相掩盖**的典型,比单纯写错断言更危险。

════ 六、 验证 ════
e2e **1218/0**(本批新增 102 条)· 真机探针 **19/0**(选人)+ **24/0**(首页/导航)
+ **34/0**(导航模块)· 桌面几何 0 失败 · 移动端几何 674/0 · 老库升级 149/0
· 部署静态断言空跑 **310/0** · **反向验证 13/13**(逐条改回旧行为,点名断言都变红)
· 线上验收:9 个新文件全在、4 张 nav 表建好(schema_version=24)、
  三条重写规则顺序正确、免登录页 / 空短语 / 错短语 / 联想门禁 全部响应正确。

════ 七、 本批的"夹具与断言"教训(都写进注释了)════
· `calendar_events.all_day` **默认值是 1** → 插夹具不写它就成了全天事件 →
  `time` 被抹成 `''` → "刻度掐头去尾"看着像没做(白查三轮)。
· 日历本要挑 `kind='custom'`(我原以为"普通本没有 kind",实际**普通本就是 custom**)。
· 量"同高/同排"要拿**内容区**(去 padding)比,不能拿 border-box(`.topline` 有 14px 上内边距)。
· 判据不能把**边界上的正确行为**当 bug(22 点有日程时末刻度**就是** 23:00)。
· 「HTML 写了、CSS 没写」e2e 永远查不出来 —— 导航展示页的登录按钮就落到了左边
  (实测按钮右 116px / 整行右 1210px),只有真机量几何才抓得到。
· 反向验证里 e2e 会**重铺副本**把注入覆盖掉 → 给 e2e 加了 `FB_PATCH_FILE` 钩子
  (重铺完自动打补丁),13 例里 8 例"没牙齿"其实是这个假象。

════ 八、 新增/改动文件 ════
新增:`app/Nav.php` · `app/NavFetch.php` · `app/WeRead.php` · `nav_show.php` ·
`nav_suggest.php` · `templates/nav/index.php` · `templates/books/weread.php` ·
`assets/css/nav-show.css` · `assets/js/nav-show.js`
改动:`app/Schema.php`(v24 + 4 张 nav 表)· `app/Module.php`(+nav)· `app/Home.php` ·
`index.php` · `templates/_person_picker.php` · `assets/js/person_picker.js` ·
`templates/home.php` · `templates/ledger/index.php` · `templates/books/_settings_menu.php` ·
`assets/css/app.css` · `router.php` · `.htaccess`
2026-10-07 05:52:40 +08:00

341 lines
14 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'],
// v24 第 13 条:纪念日(含名册里每个人的生日)
'days' => ['纪念日', '#B4633C', 'days'],
// v24 第 15 条:财务(账本 + 账目 + 共享)
'ledger' => ['财务', '#5E7A3C', 'ledger'],
// v24 第 14 批:读书(书架 + 上传解析 + 在线读 + 笔记 + 统计)
'books' => ['读书', '#6B5B95', 'books'],
// v24 第 24 批 用户第 4 条:个人导航(从 SimpleNav 搬进来,放在**读书后面**)
// 用户原话:「增加一下个人导航的这个模块就放在读书的后面,每个用户都可以
// 添加自己的导航,所有的功能模块就完全照搬原来的导航的那个系统」。
'nav' => ['导航', '#3E7A9E', 'nav'],
];
/**
* 默认出现在导航条上的(没自定义过就用它)。首页永远在,且不能被拖到最后面也不影响。
*
* ⚠️ v24(用户 2026-10-02 第 14 条):**家谱默认不上导航条** ——
* "毕竟,不需要每天都查看家谱"。家谱模块本身还在(个人设置里能勾回来),
* 只是不再占顶栏的位置。
*/
public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources',
'tasks', 'days', 'ledger', 'books', 'nav'];
/** 已经做过「家谱撤出导航条」这件事的人,记一个标记(只做一次,之后用户勾回来就尊重他) */
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)));
}
}