Files
FamilyBook/nav_show.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

281 lines
13 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
/**
* 家书 FamilyBook · 个人导航**展示页**(v24 第 24 批 用户第 4 条)
*
* ⚠️ 这是全站**唯一不需要登录**的页面 —— 用户明确要求,而且理由很实在:
* 这个页面就是要"发给别人看 / 自己随手点开"的,要登录就没意义了。
*
* 用户原话:
* 「现在的系统,每一个功能模块都是需要登录的。但是导航展示页面却不需要登录。
* 这就意味着我们需要给他一个非常特殊的地址。地址后面可以跟着用户添加的一个短语,
* 就跟类似于一个密钥一样,他可以给自己起一个短语。然后这个地址后面跟着问号
* 带上这个短语的形式去进行展示。这样的话我们是为了区分不同的用户,他的不同的
* 导航页面,用户选了一个短语之后,通过这个短语,我们就能知道要访问的是哪一个
* 用户的面板,这个页面是不需要登录的。在这个页面里面要增加一个功能,右上角
* 增加一个功能,就是登录,点击这个页面之后再进入到我们现在这个系统就是家书的
* 这个系统的登录页面。否则的话,那个导航的展示的页面是跟之前的一样,是不需要
* 进行登录的。这样的话,导航的展示页面,导航页的展示页面上面的那个头像就
* 变成用户自己的头像就可以了。」
*
* ⇒ 地址形式:`https://<站点>/nav/?q=<短语>`(也兼容 `?<短语>`,见下面两种都收)
* · 短语由用户自己起(可一键换随机码),全局唯一,就是这页的"凭据"
* · 右上角恒有一个「登录」入口,点它进家书的登录页
* · 页面顶部的头像用**他自己**的头像
*
* ⚠️ 安全上的三条硬约束(这是全站唯一免登录页,必须想清楚):
* ① **只暴露这一个用户自己的导航数据** —— 所有查询都按短语解析出的 person_id 走,
* 不接任何来自 URL 的 id(否则 `?q=xxx&person=2` 就能翻别人家);
* ② **不返回任何身份/家庭信息** —— 只有导航本身 + 头像 + 站点名。
* 连"这是谁"都不主动说(头像已经够了);
* ③ 短语按固定形式校验(4~32 位字母数字_-),拿不到它就是 404(不提示"短语不存在"
* 之外的信息,避免成为枚举靶子)。
*/
declare(strict_types=1);
require __DIR__ . '/app/bootstrap.php';
use App\Nav;
// ---- 1. 取短语(两种写法都收:?q=xxx 与 ?xxx)----
//
// ⚠️ 为什么两种都收:用户描述的是「地址后面跟着问号带上这个短语」,
// 也就是 `/nav/?yangyq` 这种**不带参数名**的形式;而现代浏览器/分享卡片
// 更习惯 `?q=`。两种都认,用户怎么写都能进,省得他记规则。
$phrase = trim((string)($_GET['q'] ?? ''));
if ($phrase === '' && !empty($_SERVER['QUERY_STRING'])) {
// `?yangyq` → QUERY_STRING 就是 `yangyq`(没有 `=` 号)
$qs = (string)$_SERVER['QUERY_STRING'];
if (strpos($qs, '=') === false) {
$phrase = urldecode($qs);
}
}
$personId = $phrase === '' ? 0 : Nav::ownerByPhrase($phrase);
// ---- 2. 找不到就是 404(不给多余信息)----
if ($personId <= 0) {
http_response_code(404);
header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>没有这个导航页</title>
<style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
background:#F7F4EF;color:#2C231D;
font:15px/1.7 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
.b{max-width:420px;padding:28px 30px;background:#fff;border:1px solid rgba(44,35,29,.10);
border-radius:16px;box-shadow:0 4px 16px rgba(44,35,29,.08);text-align:center}
h1{margin:0 0 8px;font-size:18px}
p{margin:0 0 16px;color:#7E6B5A;font-size:14px}
a{display:inline-block;padding:8px 18px;border-radius:10px;background:#A84A28;color:#fff;
text-decoration:none;font-size:14px}
</style>
</head>
<body>
<div class="b">
<h1>没有这个导航页</h1>
<p>地址里的短语不对,或者主人换过短语了。<br>确认一下地址,或者找主人要新的。</p>
<a href="<?= e(url('auth/login')) ?>">去家书登录</a>
</div>
</body>
</html>
<?php
exit;
}
// ---- 3. 取这个人的导航数据(**只这一份**)----
//
// ⚠️ 这里开始的所有查询都只认 $personId,不再接任何请求参数。
$gridGroups = Nav::groups($personId, 'grid', true);
$tabGroups = Nav::groups($personId, 'tabs', true);
$engines = Nav::engines($personId, true);
$settings = Nav::settings($personId);
$sitesOf = static function (int $gid) use ($personId): array {
return Nav::sites($personId, $gid);
};
$mePerson = \App\Person::find($personId);
$meAvatar = avatar_url($mePerson['avatar'] ?? null);
// 展示页自己的标题(用设置里的站点名与副标题;副标题允许为空 → 不显示)
$navTitle = trim((string)($settings['site_name'] ?? '')) !== '' ? (string)$settings['site_name'] : '我的导航';
$navSubtitle = (string)($settings['subtitle'] ?? '');
$primary = Nav::safeColor((string)($settings['primary_color'] ?? ''), '#2F6BF7');
$collLayout = (string)($settings['collections_layout'] ?? 'tabs') === 'flat' ? 'flat' : 'tabs';
$openTarget = Nav::openTarget($personId);
$openMode = $openTarget === '_blank' ? 'blank' : 'self';
$suggestKeys = Nav::suggestKeys($personId);
$default = Nav::defaultEngine($personId, $engines);
/** 渲染一个站点格(与 SimpleNav 的 site_tile 同构) */
$siteTile = static function (array $s) use ($openTarget): void {
$label = Nav::siteLabel($s);
$src = Nav::iconUrl((string)($s['icon'] ?? ''));
?>
<li class="site">
<a href="<?= e((string)$s['url']) ?>" target="<?= e($openTarget) ?>"<?= $openTarget === '_blank' ? ' rel="noopener"' : '' ?>
title="<?= e(trim((string)($s['name'] ?? '')) !== '' ? (string)$s['name'] : $label) ?>">
<?php if ($src !== ''): ?>
<img class="icon" src="<?= e($src) ?>" alt="" loading="lazy">
<?php else: ?>
<span class="ico-letter" style="background:<?= e(Nav::letterColor($label)) ?>"><?= e(mb_substr($label, 0, 1, 'UTF-8')) ?></span>
<?php endif; ?>
<span class="name"><?= e($label) ?></span>
</a>
</li>
<?php
};
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($navTitle) ?><?= $navSubtitle !== '' ? ' · ' . e($navSubtitle) : '' ?></title>
<meta name="robots" content="noindex,nofollow">
<meta name="theme-color" content="<?= e($primary) ?>">
<link rel="stylesheet" href="<?= e(asset('/assets/css/nav-show.css')) ?>">
<?php if ($primary !== '#2F6BF7'): ?>
<style>:root { --n-primary: <?= e($primary) ?>; }</style>
<?php endif; ?>
</head>
<body data-open="<?= e($openMode) ?>">
<div class="container">
<?php /* ⚠️ 右上角恒有「登录」入口(用户明确要求)。
它进的是**家书**的登录页,不是这个导航系统的登录页 ——
因为导航展示页本来就不需要登录,这个按钮是给"主人自己"用的便利入口。 */ ?>
<div class="topline">
<div class="who">
<?php if ($meAvatar !== ''): ?>
<img class="who-av" src="<?= e($meAvatar) ?>" alt="">
<?php endif; ?>
<span class="who-name"><?= e($navTitle) ?></span>
</div>
<a class="login-btn" href="<?= e(url('auth/login')) ?>">
<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12.5 6.5V5a1.5 1.5 0 0 0-1.5-1.5H5A1.5 1.5 0 0 0 3.5 5v10A1.5 1.5 0 0 0 5 16.5h6a1.5 1.5 0 0 0 1.5-1.5v-1.5"/>
<path d="M8 10h9m0 0-2.5-2.5M17 10l-2.5 2.5"/>
</svg>
登录
</a>
</div>
<header class="hero">
<?php if ($meAvatar !== ''): ?>
<img class="logo" src="<?= e($meAvatar) ?>" alt="">
<?php endif; ?>
<h1><?= e($navTitle) ?></h1>
<?php if ($navSubtitle !== ''): ?>
<p><?= e($navSubtitle) ?></p>
<?php endif; ?>
</header>
<?php if ($engines): ?>
<div class="s-search">
<div class="search-type">
<?php foreach ($engines as $en): ?>
<label class="s-engine<?= $default && $en['slug'] === $default['slug'] ? ' active' : '' ?>"
data-search="<?= e((string)$en['search_url']) ?>"
data-param="<?= e((string)$en['param']) ?>"
data-slug="<?= e((string)$en['slug']) ?>"
data-placeholder="<?= e((string)$en['name']) ?>搜索"
style="--ec:<?= e(Nav::safeColor((string)$en['color'], '#2F6BF7')) ?>">
<span><?= e((string)$en['name']) ?></span>
</label>
<?php endforeach; ?>
</div>
<form id="search-form" action="<?= e((string)($default['search_url'] ?? 'https://cn.bing.com/search')) ?>"
method="get" target="<?= e($openTarget) ?>"<?= $openTarget === '_blank' ? ' rel="noopener"' : '' ?>
data-slug="<?= e((string)($default['slug'] ?? '')) ?>" data-keys="<?= e($suggestKeys) ?>">
<input type="text" id="search-text" name="<?= e((string)($default['param'] ?? 'q')) ?>"
placeholder="<?= e((string)($default['name'] ?? '搜索')) ?>搜索" autocomplete="off">
<button class="submit" type="submit" aria-label="搜索">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle><path d="M20 20l-3.6-3.6"></path>
</svg>
</button>
<ul id="word"></ul>
</form>
</div>
<?php endif; ?>
<?php foreach ($gridGroups as $g): $items = $sitesOf((int)$g['id']); if (!$items) { continue; } ?>
<ul class="mylist">
<li class="title"><?= e((string)$g['name']) ?></li>
<?php foreach ($items as $s) { $siteTile($s); } ?>
</ul>
<?php endforeach; ?>
<?php
$collGroups = [];
foreach ($tabGroups as $g) {
$items = $sitesOf((int)$g['id']);
if ($items) { $collGroups[] = ['g' => $g, 'items' => $items]; }
}
?>
<?php if ($collGroups): ?>
<div class="collections<?= $collLayout === 'flat' ? ' flat' : '' ?>" data-layout="<?= e($collLayout) ?>">
<div class="collections-title">合集</div>
<?php if ($collLayout === 'flat'): ?>
<?php foreach ($collGroups as $c): ?>
<ul class="mylist">
<li class="title"><?= e((string)$c['g']['name']) ?></li>
<?php foreach ($c['items'] as $s) { $siteTile($s); } ?>
</ul>
<?php endforeach; ?>
<?php else: ?>
<div class="tabs" role="tablist">
<?php foreach ($collGroups as $i => $c): ?>
<button type="button" class="tab<?= $i === 0 ? ' active' : '' ?>" role="tab"
aria-selected="<?= $i === 0 ? 'true' : 'false' ?>"
data-target="coll-<?= (int)$c['g']['id'] ?>"><?= e((string)$c['g']['name']) ?></button>
<?php endforeach; ?>
</div>
<div class="tab-panels">
<?php foreach ($collGroups as $i => $c): ?>
<ul class="mylist tab-panel<?= $i === 0 ? ' active' : '' ?>" id="coll-<?= (int)$c['g']['id'] ?>" role="tabpanel">
<?php foreach ($c['items'] as $s) { $siteTile($s); } ?>
</ul>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php /* ⚠️ 空态:主人**还没在导航管理页里加过东西**时(`Nav::ensureSeed` 只在管理页跑,
展示页故意**不写数据** —— 它是一个免登录页面,不该被任何拿到短语的人触发出写操作),
这里会一块内容都没有。空白的公开页看起来像"坏了",所以要给一句话。 */ ?>
<?php if (!$gridGroups && !$collGroups): ?>
<div class="nav-empty">
<p>这个导航页还是空的 —— 主人还没往里加东西。</p>
<p class="small muted">(去「导航」页加几个常用网站,这里就有了。)</p>
</div>
<?php endif; ?>
<footer class="foot">
<a href="<?= e(url('auth/login')) ?>">登录家书</a>
<span>·</span>
<span><?= e($navTitle) ?></span>
</footer>
</div>
<script>
/* 联想词来源:走家书自己的代理(不直接把第三方接口暴露给浏览器 ——
SimpleNav 原来也是走自己的 suggest.php,同一个理由:跨域 + 不必要的直连)。 */
window.SIMPLENAV = {
suggest: <?= json_encode(url('nav/suggest') . '?p=' . rawurlencode($phrase), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
suggestKeys: <?= json_encode($suggestKeys) ?>
};
</script>
<script src="<?= e(asset('/assets/js/nav-show.js')) ?>"></script>
</body>
</html>