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`
This commit is contained in:
2026-10-07 05:52:40 +08:00
parent ae3060f552
commit 09e30d27d4
21 changed files with 5034 additions and 180 deletions
+213
View File
@@ -0,0 +1,213 @@
<?php
/**
* 家书 FamilyBook · 导航搜索联想代理(v24 第 24 批 用户第 4 条,从 SimpleNav 搬来)
*
* ⚠️ 浏览器直接调各家联想接口会撞跨域 / JSONP 限制,所以由服务端代理并统一输出 JSON。
* SimpleNav 原文这里有一段**线上实测结论**,照抄保留(它是最贵的部分):
*
* 百度 sugrec?prod=pc&wd= 200 / 0.12s / 1137B ✅ 最快最稳 → 默认来源
* 360 sug.so.360.cn/suggest 200 / 0.20s / 714B ✅
* 必应 api.bing.com/osjson 200 / 0.5~1.6s / 408B ✅ 可用但明显慢
* 搜狗 suggnew/ajajjson 200 / **0 字节** ❌ 联想域名已不解析
* 谷歌 suggestqueries.google 连接超时 ❌ 国内不可达
* DuckDuckGo 连接超时 ❌ 国内不可达
*
* 所以默认策略是「**联想词统一取百度,搜索仍走各自引擎**」——
* 点哪个引擎就用哪个引擎搜,只是下拉建议来自百度,任何一个引擎都不会出现"下拉是空的"。
*
* 用法:`/nav/suggest.php?p=<短语>&slug=bing&q=关键词`
*
* ⚠️⚠️ **这个接口也是免登录的**(展示页本身免登录,它的联想接口必须一起免登录,
* 否则展示页的搜索框就没法联想)。因此它必须有自己的门禁 —— 见下面"门禁"那段:
* **必须带一个有效的短语**才放行,否则任何人都能拿它当免费代理刷外网请求。
*/
declare(strict_types=1);
require __DIR__ . '/app/bootstrap.php';
use App\Nav;
use App\NavFetch;
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('X-Robots-Tag: noindex');
// ---- 门禁:必须凭一个有效的短语(见文件头说明)----
//
// ⚠️ 这**不是**身份校验(短语本来就是公开的),它挡的是"随便谁来都能刷":
// 没有它,这个 URL 就是一个匿名的外网请求放大器(每次 GET 都会去打百度/必应)。
$phrase = trim((string)($_GET['p'] ?? ''));
$owner = $phrase === '' ? 0 : Nav::ownerByPhrase($phrase);
if ($owner <= 0) {
http_response_code(403);
echo json_encode(['q' => '', 'items' => [], 'src' => '', 'error' => 'forbidden'],
JSON_UNESCAPED_UNICODE);
exit;
}
$slug = isset($_GET['slug']) ? (string)preg_replace('/[^a-z0-9\-_]/i', '', (string)$_GET['slug']) : '';
$q = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
// 联想词是给人看着点的短词,限长免得被拿去当搜索接口刷
$q = mb_substr($q, 0, 60, 'UTF-8');
if ($q === '') {
echo json_encode(['q' => '', 'items' => [], 'src' => ''], JSON_UNESCAPED_UNICODE);
exit;
}
// ---- 取这个人的引擎表(联想来源也按**他的**引擎配置走)----
$engines = Nav::engines($owner);
/** 按 slug 找他的某个引擎 */
$engineBy = static function (string $s) use ($engines): ?array {
if ($s === '') {
return null;
}
foreach ($engines as $e) {
if ((string)$e['slug'] === $s) {
return $e;
}
}
return null;
};
/**
* 排出「先试谁、再试谁」的候选顺序 —— 照搬 SimpleNav 的 suggest_order()。
*
* 兜底链固定「百度 → 必应 → 360」(实测可用的三家,按快慢排序),
* 只保留库里真实存在、且配了 suggest_url 的引擎。
*
* ⚠️ SimpleNav 那个 setting('suggest_source') 开关这里**没搬** ——
* 它依赖项目级设置,而本模块是**每人一份**的;为了不给用户多一个看不懂的开关,
* 直接用最有用的那一种(即 auto 的兜底链行为)。这是本批唯一有意"少搬"的一处,
* 在此记明,免得下一个人以为是漏了。
*/
$order = [];
if ($slug !== '') {
$own = $engineBy($slug);
if ($own !== null && (string)$own['suggest_url'] !== '') {
$order[] = $own;
}
}
foreach (['baidu', 'bing', 'so360'] as $s) {
$row = $engineBy($s);
if ($row !== null && (string)$row['suggest_url'] !== '') {
$order[] = $row;
}
}
$items = [];
$used = '';
foreach ($order as $en) {
$items = nav_fetch_suggest($en, $q);
if ($items) {
$used = (string)$en['slug'];
break;
}
}
$items = array_values(array_unique(array_filter(array_map('trim', $items))));
$items = array_slice($items, 0, 10);
echo json_encode(['q' => $q, 'items' => $items, 'src' => $used], JSON_UNESCAPED_UNICODE);
exit;
/* ========================================================================== */
/** 抓一次联想并解析;任何异常都退化成空数组,绝不让下拉框卡住(照搬原函数) */
function nav_fetch_suggest(array $engine, string $q): array
{
if ((string)($engine['suggest_url'] ?? '') === '') {
return [];
}
$u = str_replace('{q}', rawurlencode($q), (string)$engine['suggest_url']);
// 3 秒。既然有兜底链,宁快速失败也不要让输入框等太久。
$resp = NavFetch::get($u, 100000, 3);
if ($resp === null) {
return [];
}
return nav_parse_suggest((string)$engine['suggest_type'], (string)$resp['body'], $q);
}
/** 按引擎类型解析联想结果(照搬原函数,逻辑一行未改) */
function nav_parse_suggest(string $type, string $body, string $q): array
{
$body = trim($body);
$out = [];
unset($q);
switch ($type) {
case 'bing_osjson':
case 'google_firefox':
// ["query", ["s1","s2",...]]
$j = json_decode($body, true);
if (is_array($j) && isset($j[1]) && is_array($j[1])) {
$out = $j[1];
}
break;
case 'baidu_sugrec':
// {"q":"x","g":[{"type":"..","q":".."}], ...}
$j = json_decode($body, true);
if (is_array($j)) {
if (!empty($j['g']) && is_array($j['g'])) {
foreach ($j['g'] as $it) {
if (isset($it['q'])) { $out[] = $it['q']; }
}
} elseif (!empty($j['s']) && is_array($j['s'])) {
$out = $j['s'];
}
}
break;
case 'so360':
// {"s":[".."]} 或 {"result":[{"word":".."}]}
$j = json_decode($body, true);
if (is_array($j)) {
if (!empty($j['s']) && is_array($j['s'])) {
$out = $j['s'];
} elseif (!empty($j['result']) && is_array($j['result'])) {
foreach ($j['result'] as $it) {
if (isset($it['word'])) { $out[] = $it['word']; }
}
}
}
break;
case 'sogou_ajaj':
// {"query":"x","items":["a","b"]}(可能 GBK,已被 NavFetch 转码)
$j = json_decode($body, true);
if (is_array($j) && !empty($j['items']) && is_array($j['items'])) {
$out = $j['items'];
} elseif (preg_match_all('#"([^"]{1,40})"#u', $body, $m)) {
$out = array_slice($m[1], 1); // 跳过 query 自身
}
break;
case 'ddg':
// [{"phrase":".."},...]
$j = json_decode($body, true);
if (is_array($j)) {
foreach ($j as $it) {
if (is_array($it) && isset($it['phrase'])) {
$out[] = $it['phrase'];
} elseif (is_string($it)) {
$out[] = $it;
}
}
}
break;
default:
// 通用兜底:先试 JSON,再退化正则(照搬)
$j = json_decode($body, true);
if (is_array($j)) {
if (isset($j[1]) && is_array($j[1])) {
$out = $j[1];
} elseif (!empty($j['s']) && is_array($j['s'])) {
$out = $j['s'];
}
}
}
return is_array($out) ? $out : [];
}