Files
laoyang c8839fa728 v26 第 26 批(5 条):财务多选按集合取图 / 首页全天置顶与红线定位 / 任务复选框去提示与看板颜色 / 导航搜索引擎切换与站点复用 / 详情页去复选框
- 财务:勾选多个标签时上面那张「总金额」图按**选中集合**统计(新增
  ledger/total_chart 路由 + 单一容器 data-ck-total-host,按集合现取并缓存;
  Ledger::timeline 第二参数放宽为 string|array)
- 首页日程:全天日程移出滚动容器固定置顶;进场把当前时刻红线居中
  (scrollTop 计算,不用 scrollIntoView),触发条件不绑 $showNow
- 任务:复选框去除 title 提示;看板可设颜色(Task::boardColor/updateBoard),
  首页任务行用该颜色显示所属看板
- 导航:展示页补回 id=engine-tabs(切换搜索引擎无效的根因);补内置引擎;
  新增站点输 URL 可复用他人已加过的站点(sharedByUrl/copyIconFile/adoptSite)
- 任务详情页页头去掉复选框(已有「标记完成」按钮)
2026-10-07 15:31:26 +08:00

304 lines
15 KiB
PHP
Raw Permalink 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">
<?php /* 🔴 v24 第 26 批 用户第 4 条 修的真 bug:
`nav-show.js` 找的是 `#engine-tabs`(SimpleNav 原版的 id),
而这里搬过来时只写了 `class="search-type"` —— **id 丢了**。
后果:`querySelectorAll('#engine-tabs .s-engine')` 命中 0 个 →
点标签一个事件都没绑上 → 用户看到的正是「选不同的搜索引擎没用」。
修法:把 id 补回来(而不是去改 JS —— 那份 JS 要与 SimpleNav 原版对照维护)。 */ ?>
<div class="search-type" id="engine-tabs">
<?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>