名册点击看详情拖动才排序 / 任务复选框与状态 / 财务标签多选 / 微信读书同步与统计 /
导航线上数据与图标
用户本轮 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() 不再报错。
298 lines
14 KiB
PHP
298 lines
14 KiB
PHP
<?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>
|