Files
FamilyBook/nav_show.php
T
laoyang a5e559b2b3 v25 第 25 批(9 条):名册分组不刷页 / 纪念日多人 / 首页等高 / 移出不回流 /
名册点击看详情拖动才排序 / 任务复选框与状态 / 财务标签多选 / 微信读书同步与统计 /
导航线上数据与图标

用户本轮 9 条需求(原话见 templates/ 与 app/ 内的逐条注释):

1. 名册:列表里给某人分组改为 AJAX 就地挪卡片(不再整页刷新)。
2. 纪念日:关联人改**多选**(复用 _person_picker,字段 person_ids[]);
   自建纪念日的按钮与其它模块统一(改=蓝 .btn、删=黄 .btn-warn);
   列表新增「关联人」列并给人名前加头像。
3. 首页:日程/任务两块**完全等高**(align-items:stretch + 固定总高 + flex 子项
   min-height:0);修「0 件待办」——数字与右侧列表同源(原来各算各的)。
4. 🔴 名册里被移出的人过一阵又回来:根因是 PersonRoster::seedFromPersons()
   **每次 Schema::ensure() 都无条件重灌**(线上实测每跑一次 +1 行)。
   修:给它加自守卫(只有在 persons 还留着 group_id/sort_order 的**老库**才跑)。
5. 名册:点击=看详情、**只有拖 ⠿ 手柄**才是排序(原来整行既能点又能拖,语义混乱)。
6. 任务:所有任务前加复选框,任何位置点勾即完成;
   状态语义照业界整理为 To Do / Doing / Done 并写出说明(用户要求参考别人的做法)。
7. 财务:类别标签改**多选非互斥**(再点一次取消该类别),上下两图 + 图例 + 表格
   全部按同一个集合联动;高亮条刻意不给「合计」(多选下各类金额不能相加)。
8. 微信读书:新增**懒同步**(访问时顺手做,6h TTL,只补空字段不覆盖用户填的)、
   封面图**拉下来存本站存储**(原来只存外链 → 看不到)、接官方 /readdata/detail 统计。
9. 导航:线上数据只搬了一半(5 板块 29 站 → 7 板块 67 站 + 67 图标)、
   展示页头像不显示 / 搜索按钮错位 / 点登录 404。三个真根因:
   · 相对地址在 /nav/ 下全 404 → 新增 Nav::abs();
   · 搜索容器丢了 id="search"(CSS 是 #search button.submit 绝对定位);
   · Nav::iconKey() 与 SimpleNav 的 icon_key() **算法不一致**(少一次 md5 归一化)
     → 搬来的图标文件名对不上;且 uploads/ 整目录 Require all denied。
   新增两个**免登录公开出口** nav_icon.php / nav_avatar.php。

顺带修掉一个「数据损坏级」的真 bug(做第 4 条线上反证时发现的,之前一直没人注意):
app/Region.php 三处用中文标点当 trim/ltrim 的 charlist,而 PHP 的 charlist 是
**按字节**匹配的 —— 「。」= E3 80 **82** → 字节 82 进了可剥集合 →
「层」= E5 B1 **82** 被切掉尾字节 → 非法 UTF-8 → backfillAddress 的 UPDATE 报
`1366 Incorrect string value` → **地址拆分升级从上线起一直在失败被跳过**(每请求报一次)。
改成字符级 preg_replace('/…/u');线上实测 ensure() 现在无报错、待拆行数真的在下降。

验证战绩:
- e2e 1289/0(新增 Region 字节级用例等)
- 真机探针:选人 19/0 · 首页/导航 24/0 · 导航模块 34/0 · b18 暂存+确定 10/0 ·
  第23批 52/0
- 桌面几何 428/0 · 移动端几何 680/0 · 交互 92/0 · 窄屏 24/0
- 老库升级 153/0(含 anniversary_people 迁移 + 「再跑 ensure() 名册行数不变」)
- 部署静态断言 180 OK / 0 FAIL · 反向验证 3/3 + Region 三变体各被两条断言同时抓住
- 线上 26 个文件与本地**逐字节一致**;展示页 200、图标 66/66 200、登录 200、
  越权/不存在均 404、签发短语后 ensure() 不再报错。
2026-10-07 11:35:31 +08:00

298 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
/**
* 家书 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(Nav::abs('/' . 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);
/* 🔴🔴 展示页里的自引用地址**一律要绝对化**(v24 第 25 批 用户第 9 条修的真 bug)。
*
* 根因:家书的 `url()` 产出 `index.php?r=xxx`、`avatar_url()` 产出 `media.php?k=…`
* —— 都是**相对地址**。而本页在 `/nav/` 子目录下,浏览器会把它们拼成
* `/nav/index.php?…`、`/nav/media.php?…` —— 线上实测**全部 404**。
* 用户看到的正是这一串:头像不显示 + 点「登录」404 + 搜索联想请求也 404。
* 修法见 Nav::abs()(它解释了为什么只在这一页需要绝对化)。
*
* 头像另外有一层:`media.php` **要登录**、`uploads/` 又被 `Require all denied`
* (线上实测 403),免登录页两头都取不到 → 所以走专门开的 `nav_avatar.php`
* (只服务这个短语主人自己的头像)。没有头像时它 404,前端 `onerror` 换成默认剪影。 */
$meAvatar = $mePerson && trim((string)($mePerson['avatar'] ?? '')) !== ''
? Nav::abs('/nav_avatar.php') . '?p=' . rawurlencode($phrase)
: '';
$defaultAvatar = Nav::abs(asset('/assets/avatar-default.svg'));
$loginUrl = Nav::abs('/' . url('auth/login'));
$suggestUrl = Nav::abs('/nav_suggest.php') . '?p=' . rawurlencode($phrase);
// 展示页自己的标题(用设置里的站点名与副标题;副标题允许为空 → 不显示)
$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(Nav::abs(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">
<img class="who-av" src="<?= e($meAvatar !== '' ? $meAvatar : $defaultAvatar) ?>"
alt="" onerror="this.onerror=null;this.src='<?= e($defaultAvatar) ?>';">
<span class="who-name"><?= e($navTitle) ?></span>
</div>
<a class="login-btn" href="<?= e($loginUrl) ?>">
<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">
<img class="logo" src="<?= e($meAvatar !== '' ? $meAvatar : $defaultAvatar) ?>"
alt="" onerror="this.onerror=null;this.src='<?= e($defaultAvatar) ?>';">
<h1><?= e($navTitle) ?></h1>
<?php if ($navSubtitle !== ''): ?>
<p><?= e($navSubtitle) ?></p>
<?php endif; ?>
</header>
<?php if ($engines): ?>
<div id="search" 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($loginUrl) ?>">登录家书</a>
<span>·</span>
<span><?= e($navTitle) ?></span>
</footer>
</div>
<script>
/* 联想词来源:走家书自己的代理(不直接把第三方接口暴露给浏览器 ——
SimpleNav 原来也是走自己的 suggest.php,同一个理由:跨域 + 不必要的直连)。
⚠️ v24 第 25 批:地址必须用 Nav::abs() **绝对化** —— 本页在 /nav/ 下,
相对地址会被拼成 /nav/nav_suggest.php…(线上实测 404,下拉永远空)。 */
window.SIMPLENAV = {
suggest: <?= json_encode($suggestUrl, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
suggestKeys: <?= json_encode($suggestKeys) ?>
};
</script>
<script src="<?= e(Nav::abs(asset('/assets/js/nav-show.js'))) ?>"></script>
</body>
</html>