Files
FamilyBook/app/Nav.php
T
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

1028 lines
47 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
/**
* 个人导航(v24 第 24 批 用户第 4 条)—— 从用户自己的 SimpleNav 项目**完整搬运**。
*
* 用户原话:
* 「你去读一下个人导航,就是我之前有一个个人导航的项目,把这个项目完整的搬过来
* 放在导航条里,就是说增加一下个人导航的这个模块就放在读书的后面,每个用户
* 都可以添加自己的导航,所有的功能模块就完全照搬原来的导航的那个系统里面的功能。
* 但是这里面有一个问题需要注意现在的系统,每一个功能模块都是需要登录的。
* 但是导航展示页面却不需要登录。这就意味着我们需要给他一个非常特殊的地址。
* 地址后面可以跟着用户添加的一个短语,就跟类似于一个密钥一样,他可以给自己
* 起一个短语。然后这个地址后面跟着问号带上这个短语的形式去进行展示。这样的话
* 我们是为了区分不同的用户,他的不同的导航页面,用户选了一个短语之后,通过
* 这个短语,我们就能知道要访问的是哪一个用户的面板,这个页面是不需要登录的。
* 在这个页面里面要增加一个功能,右上角增加一个功能,就是登录点击这个页面之后
* 再进入到我们现在这个系统就是家书的这个系统的登录页面。否则的话,那个导航的
* 展示的页面是跟之前的一样,是不需要进行登录的。这样的话,导航的展示页面,
* 导航页的展示页面上面的那个头像就变成用户自己的头像就可以了。」
*
* ────────────────────────────────────────────────────────────────
* **搬运时改了什么、没改什么**(这是唯一需要判断的地方)
*
* 没改(照搬):板块/站点/引擎/设置四张表的字段语义、稀疏排序 SORT_STEP、
* 拖拽整组归一化、图标抓取与 GD 生成站点图标、联想词接口、打开方式、
* 合集 tabs/flat 两种布局 —— 用户要的是「完全照搬」。
*
* 改了两处(不改会坏):
* ① **存储**:原项目是独立 SQLite(data/nav.db)+ 自己的 users 表。
* 搬进来改成家书的 MySQL(`nav_*` 表)+ **家书的 person_id**。
* ② **归属维度**:原项目只有一份全局数据(单用户系统);
* 现在是「每个用户都可以添加自己的导航」→ 所有查询都带上 `person_id`。
* ⚠️ 这正是"照搬"里唯一不能照抄的部分:漏掉这一维,全家人会共用同一份导航,
* 而且一个人加一条链接,全家人的导航页里都会冒出来。
* ③ 原来的 `url()` / `asset()` / `e()` 等与家书重名,**一律改用家书自己的**
* (家书已有 `e()` / `asset()` / `url()`),不再搬一份进来 ——
* 两份同名函数会直接 Fatal(函数重定义)。
*/
declare(strict_types=1);
namespace App;
final class Nav
{
/** 排序步长(与 SimpleNav 一致) */
public const SORT_STEP = 1000;
/** 图标目录(相对站点根,Web 可访问;与 SimpleNav 的 uploads/icons 同构) */
public const ICON_DIR = 'uploads/nav-icons';
public const ICON_URL = 'uploads/nav-icons';
/**
* 导航短语的合法形式:4~32 位,字母/数字/下划线/连字符。
*
* ⚠️ 为什么限这么死:这个短语是**免登录地址**上的唯一凭据
* (`/nav/?yangyq`)。允许中文/符号会让复制粘贴时出现"看起来一样但实际不同"
* 的重码(中文空格、全角下标…),而这种错误排查起来极烦。
* 长度下限 4 是因为 1~3 位太容易被穷举(这页面是公开的)。
*/
public const PHRASE_RE = '/^[A-Za-z0-9_-]{4,32}$/';
/** 保留短语:不能与真实路由/常见词撞(否则地址会有歧义) */
public const PHRASE_RESERVED = [
'nav', 'login', 'logout', 'index', 'admin', 'api', 'static', 'assets',
'help', 'about', 'null', 'undefined', 'true', 'false', 'test',
];
// ================================================================
// 设置(原 SimpleNav 的 settings 表 → 这里按人一份)
// ================================================================
/** 设置的默认值(照搬 SimpleNav 的 seed_settings) */
public const SETTING_DEFAULTS = [
'site_name' => '我的导航',
'subtitle' => '',
'default_engine' => 'bing',
'primary_color' => '#2F6BF7',
// '1' = 站点链接与搜索框都在**新标签页**打开(与引入该设置前的旧行为一致)
'open_blank' => '1',
// 'both' = ↓/↑ 与 , . 两组都认(SimpleNav 的默认,也是"严格超集")
'suggest_keys' => 'both',
// tabs = 合集标签页折叠(默认);flat = 全部平铺
'collections_layout' => 'tabs',
];
public static function setting(int $personId, string $k, ?string $default = null): string
{
if ($default === null) {
$default = self::SETTING_DEFAULTS[$k] ?? '';
}
$st = Database::get()->prepare('SELECT v FROM nav_settings WHERE person_id = ? AND k = ?');
$st->execute([$personId, $k]);
$v = $st->fetchColumn();
return $v === false ? $default : (string)$v;
}
public static function setSetting(int $personId, string $k, string $v): void
{
Database::get()->prepare(
'INSERT INTO nav_settings (person_id, k, v) VALUES (?,?,?)
ON DUPLICATE KEY UPDATE v = VALUES(v)'
)->execute([$personId, $k, $v]);
}
/** 一次取全部设置(渲染页面时省 N 次查询) */
public static function settings(int $personId): array
{
$out = self::SETTING_DEFAULTS;
$st = Database::get()->prepare('SELECT k, v FROM nav_settings WHERE person_id = ?');
$st->execute([$personId]);
foreach ($st->fetchAll() as $r) {
$out[(string)$r['k']] = (string)$r['v'];
}
return $out;
}
// ================================================================
// 板块
// ================================================================
/** 我的板块(可按显示方式筛) */
public static function groups(int $personId, string $display = '', bool $visibleOnly = false): array
{
$sql = 'SELECT * FROM nav_groups WHERE person_id = ?';
$arg = [$personId];
if ($display !== '') {
$sql .= ' AND display = ?';
$arg[] = $display;
}
if ($visibleOnly) {
$sql .= ' AND is_visible = 1';
}
$sql .= ' ORDER BY sort_order, id';
$st = Database::get()->prepare($sql);
$st->execute($arg);
return $st->fetchAll();
}
public static function group(int $personId, int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM nav_groups WHERE id = ? AND person_id = ?');
$st->execute([$id, $personId]);
$r = $st->fetch();
return $r ?: null;
}
public static function saveGroup(int $personId, array $d, int $id = 0): int
{
$name = trim((string)($d['name'] ?? ''));
$display = ((string)($d['display'] ?? 'grid')) === 'tabs' ? 'tabs' : 'grid';
$vis = !empty($d['is_visible']) ? 1 : 0;
if ($name === '') {
return 0;
}
if ($id > 0) {
// ⚠️ 带 person_id 校验:不能凭一个 id 改别人家的板块
Database::get()->prepare(
'UPDATE nav_groups SET name = ?, display = ?, is_visible = ? WHERE id = ? AND person_id = ?'
)->execute([$name, $display, $vis, $id, $personId]);
return $id;
}
$sort = self::nextSort('nav_groups', $personId);
Database::get()->prepare(
'INSERT INTO nav_groups (person_id, name, slug, display, sort_order, is_visible)
VALUES (?,?,?,?,?,?)'
)->execute([$personId, $name, self::slugify($name), $display, $sort, $vis]);
return (int)Database::get()->lastInsertId();
}
public static function removeGroup(int $personId, int $id): void
{
// 站点靠外键 ON DELETE CASCADE 一起走
Database::get()->prepare('DELETE FROM nav_groups WHERE id = ? AND person_id = ?')
->execute([$id, $personId]);
}
public static function toggleGroup(int $personId, int $id): void
{
Database::get()->prepare(
'UPDATE nav_groups SET is_visible = 1 - is_visible WHERE id = ? AND person_id = ?'
)->execute([$id, $personId]);
}
// ================================================================
// 站点
// ================================================================
public static function sites(int $personId, int $groupId, bool $visibleOnly = true): array
{
$sql = 'SELECT * FROM nav_sites WHERE person_id = ? AND group_id = ?'
. ($visibleOnly ? ' AND is_visible = 1' : '') . ' ORDER BY sort_order, id';
$st = Database::get()->prepare($sql);
$st->execute([$personId, $groupId]);
return $st->fetchAll();
}
/** 我的全部站点(管理页用;按板块归好组) */
public static function sitesByGroup(int $personId): array
{
$st = Database::get()->prepare(
'SELECT * FROM nav_sites WHERE person_id = ? ORDER BY group_id, sort_order, id'
);
$st->execute([$personId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['group_id']][] = $r;
}
return $out;
}
public static function site(int $personId, int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM nav_sites WHERE id = ? AND person_id = ?');
$st->execute([$id, $personId]);
$r = $st->fetch();
return $r ?: null;
}
/**
* 存一条站点(新增或编辑)。
*
* ⚠️ `icon_custom` 的语义照搬 SimpleNav:「全部更新」抓图标时**不动**
* 用户自己传的图标。漏掉这一条,用户精心挑的图标会被下一次批量更新冲掉。
*/
public static function saveSite(int $personId, array $d, int $id = 0): int
{
$url = self::normUrl((string)($d['url'] ?? ''));
$name = trim((string)($d['name'] ?? ''));
$short = trim((string)($d['short_name'] ?? ''));
$gid = (int)($d['group_id'] ?? 0);
$vis = !empty($d['is_visible']) ? 1 : 0;
if ($url === '' || $gid <= 0 || self::group($personId, $gid) === null) {
return 0;
}
if ($name === '') {
$name = self::domainOf($url);
}
$now = date('Y-m-d H:i:s');
if ($id > 0) {
Database::get()->prepare(
'UPDATE nav_sites SET group_id = ?, url = ?, name = ?, short_name = ?, is_visible = ?,
updated_at = ? WHERE id = ? AND person_id = ?'
)->execute([$gid, $url, $name, $short, $vis, $now, $id, $personId]);
return $id;
}
// ⚠️ 新增一律排**最后**(SimpleNav 的明确行为:不插进用户已排好的顺序里)
$sort = self::nextSort('nav_sites', $personId, 'group_id', $gid);
Database::get()->prepare(
'INSERT INTO nav_sites (person_id, group_id, url, name, short_name, icon, icon_custom,
sort_order, is_visible, created_at, updated_at)
VALUES (?,?,?,?,?,\'\',0,?,?,?,?)'
)->execute([$personId, $gid, $url, $name, $short, $sort, $vis, $now, $now]);
return (int)Database::get()->lastInsertId();
}
public static function removeSite(int $personId, int $id): void
{
$s = self::site($personId, $id);
if ($s && (string)$s['icon'] !== '' && empty($s['icon_custom'])) {
self::deleteIconFile((string)$s['icon']);
}
Database::get()->prepare('DELETE FROM nav_sites WHERE id = ? AND person_id = ?')
->execute([$id, $personId]);
}
public static function toggleSite(int $personId, int $id): void
{
Database::get()->prepare(
'UPDATE nav_sites SET is_visible = 1 - is_visible WHERE id = ? AND person_id = ?'
)->execute([$id, $personId]);
}
/** 站点显示名(简称优先 → 抓来的名字 → 域名) */
public static function siteLabel(array $s): string
{
$short = trim((string)($s['short_name'] ?? ''));
if ($short !== '') {
return $short;
}
$n = trim((string)($s['name'] ?? ''));
return $n !== '' ? $n : self::domainOf((string)($s['url'] ?? ''));
}
// ================================================================
// 跨用户复用同一个站点(v24 第 26 批 用户第 4 条后半)
//
// 用户原话:「导航,每个人添加新的导航,输入 url 之后,如果其他用户添加了,
// 他可以直接复用,复用的时候,自定义图标和简称,他可以自己再设置,
// 设置好以后就固定了。」
//
// 三条语义,逐条落成代码:
// ① **输入 URL 之后**才知道能不能复用 → 需要一个"按 URL 查"的接口
// (`sharedByUrl()`),供前端在输入框失焦时问一次。
// ② "他可以直接复用" → 复用的是一个**模板**:把别人那条的
// 名字 / 图标 / 颜色搬过来当预填,**不是**共享同一条记录
// (共享一条的话,A 改简称会改到 B 的导航上 —— 那谁也说不清是谁的)。
// ③ "设置好以后就固定了" → 复用来的图标必须标 `icon_custom = 1`
// (见下面 `adoptSite()` 的说明:否则下次「全部更新」会把它冲掉)。
//
// ⚠️ 隐私边界:这里**只暴露"这个 URL 有人加过"以及可复用的展示信息**
// (名字 / 简称 / 图标文件名),**不暴露是谁加的**、也不暴露他还有哪些站。
// 导航本身就是"要给别人看的"(展示页公开),所以这几个字段不算隐私;
// 但"谁加过什么"是另一回事,不查也不给。
// ================================================================
/**
* 按 URL 找**别人**已经加过的同款站点(用来做"可复用"提示与预填)。
*
* @return array<int,array> 最多返回几条(同一 URL 可能被多个人加过,去重后取前几个)
*/
public static function sharedByUrl(int $personId, string $url, int $limit = 6): array
{
$norm = self::normUrl($url);
if ($norm === '') {
return [];
}
// ⚠️ 用**归一化后的 URL** 比:用户手输时 https / 末尾斜杠 / 大小写都可能不同,
// 直接比原串的话"同一个站点"会认不出来(这正是复用的价值所在)。
// 库里存的也是归一化后的(saveSite 里就走 normUrl),所以等价比较成立。
$st = Database::get()->prepare(
'SELECT url, name, short_name, icon, icon_custom
FROM nav_sites
WHERE person_id <> ? AND url = ?
ORDER BY (short_name <> "") DESC, id ASC
LIMIT ' . max(1, min(20, $limit))
);
$st->execute([$personId, $norm]);
$out = [];
$seen = [];
foreach ($st->fetchAll() as $r) {
// 同一 URL 多人加过 → 按"显示名 + 图标"去重(对用户来说它们就是同一条)
$key = self::siteLabel($r) . '|' . (string)$r['icon'];
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = $r;
}
return $out;
}
/**
* 把别人那条站点的展示信息**搬到自己这条**上(复用)。
*
* 与 `saveSite()` 分开写而不是给它加参数:`saveSite` 是"用户手填"那条路,
* 这里是"照抄别人"那条路 —— 两者的**校验重点不同**(前者校验输入合法性,
* 后者校验"那个图标文件还在不在")。混在一个函数里会让两条路的判断互相干扰。
*
* @param array $from sharedByUrl() 返回的一条
* @param int $siteId 自己站点表里那条新建记录的 id
*/
public static function adoptSite(int $personId, int $siteId, array $from, string $name, string $short): bool
{
$db = Database::get();
$row = self::site($personId, $siteId);
if (!$row) {
return false;
}
$name = trim($name);
$short = trim($short);
if ($name === '' && $short === '') {
// 用户什么都没填 → 用别人的显示名当兜底(否则这条站没有名字可显示)
$name = self::siteLabel($from);
}
$icon = '';
$isCus = 0;
$srcIcon = trim((string)($from['icon'] ?? ''));
if ($srcIcon !== '') {
/* 🔴 "设置好以后就固定了" 这句话的落点就在这里。
*
* 复用的图标是**别人抓的**(或别人自定义的)。搬过来时必须标
* `icon_custom = 1`,否则「全部更新」会按本站的抓取规则重新抓一遍
* —— 那时抓到的可能是另一个图标,用户会看到"我明明选好了,怎么变了"。
* 标成自定义 = 从此不再自动改它,与用户自己上传图标同等待遇。
* ⚠️ 文件是**复制**一份到本站点名下(不是引用别人那个):
* 否则别人删站 / 更新图标会连带影响你的展示页。
*/
$copied = self::copyIconFile($srcIcon);
if ($copied !== '') {
$icon = $copied;
$isCus = 1;
}
}
$db->prepare(
'UPDATE nav_sites SET name = ?, short_name = ?, icon = ?, icon_custom = ?,
updated_at = ? WHERE id = ? AND person_id = ?'
)->execute([$name, $short, $icon, $isCus, date('Y-m-d H:i:s'), $siteId, $personId]);
return true;
}
/**
* 把图标文件复制一份、返回新文件名(失败返回 '')。
*
* ⚠️ 只认 `ICON_DIR` 下的**纯文件名**(与 iconUrl/deleteIconFile 同一套判据):
* 带斜杠或 `..` 一律拒绝 —— 否则"复用"会变成一个任意文件读取的口子。
*/
public static function copyIconFile(string $file): string
{
$file = trim($file);
if ($file === '' || strpos($file, '/') !== false || strpos($file, '\\') !== false
|| strpos($file, '..') !== false) {
return '';
}
$dir = FB_ROOT . '/' . self::ICON_DIR;
$abs = $dir . '/' . $file;
if (!is_file($abs)) {
return ''; // 源文件不在了(别人删过)→ 不复制,退化成"没有图标"
}
// 新文件名:保持同样的扩展名(图标出口按扩展名给 Content-Type)
$ext = strtolower((string)pathinfo($file, PATHINFO_EXTENSION));
if (!preg_match('/^[a-z0-9]{1,5}$/', $ext)) {
$ext = 'png';
}
$new = bin2hex(random_bytes(8)) . '.' . $ext;
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
return '';
}
return @copy($abs, $dir . '/' . $new) ? $new : '';
}
// ================================================================
// 搜索引擎
// ================================================================
public static function engines(int $personId, bool $activeOnly = false): array
{
$st = Database::get()->prepare(
'SELECT * FROM nav_engines WHERE person_id = ?'
. ($activeOnly ? ' AND is_active = 1' : '') . ' ORDER BY sort_order, id'
);
$st->execute([$personId]);
return $st->fetchAll();
}
public static function saveEngine(int $personId, array $d, int $id = 0): int
{
$name = trim((string)($d['name'] ?? ''));
$slug = trim((string)($d['slug'] ?? ''));
$url = trim((string)($d['search_url'] ?? ''));
$par = trim((string)($d['param'] ?? 'q'));
if ($name === '' || $url === '') {
return 0;
}
if ($slug === '') {
$slug = self::slugify($name);
}
if ($par === '') { $par = 'q'; }
$color = self::safeColor((string)($d['color'] ?? ''), '#2F6BF7');
if ($id > 0) {
Database::get()->prepare(
'UPDATE nav_engines SET name=?, slug=?, search_url=?, param=?, suggest_url=?,
suggest_type=?, color=? WHERE id=? AND person_id=?'
)->execute([$name, $slug, $url, $par,
trim((string)($d['suggest_url'] ?? '')), trim((string)($d['suggest_type'] ?? '')),
$color, $id, $personId]);
return $id;
}
$sort = self::nextSort('nav_engines', $personId);
Database::get()->prepare(
'INSERT INTO nav_engines (person_id, name, slug, search_url, param, suggest_url,
suggest_type, color, is_active, sort_order)
VALUES (?,?,?,?,?,?,?,?,0,?)'
)->execute([$personId, $name, $slug, $url, $par,
trim((string)($d['suggest_url'] ?? '')), trim((string)($d['suggest_type'] ?? '')),
$color, $sort]);
return (int)Database::get()->lastInsertId();
}
public static function removeEngine(int $personId, int $id): void
{
Database::get()->prepare('DELETE FROM nav_engines WHERE id = ? AND person_id = ?')
->execute([$id, $personId]);
}
public static function toggleEngine(int $personId, int $id): void
{
Database::get()->prepare(
'UPDATE nav_engines SET is_active = 1 - is_active WHERE id = ? AND person_id = ?'
)->execute([$id, $personId]);
}
/** 默认引擎(设置里指定的那个;不在就取第一个启用的) */
public static function defaultEngine(int $personId, array $engines): ?array
{
$want = self::setting($personId, 'default_engine');
foreach ($engines as $e) {
if ((string)$e['slug'] === $want) {
return $e;
}
}
return $engines[0] ?? null;
}
// ================================================================
// 排序(稀疏 + 整组归一化)—— 照搬 SimpleNav 的两个函数
// ================================================================
/**
* 取「同组最后一条的 sort_order + SORT_STEP」——**新增条目专用**。
*
* ⚠️ 不要改回 `MAX+1`:SimpleNav 里已经写明过原因 ——
* 那只在"旧数据恰好是连续整数"时才等于"排最后";一旦排序号之间有间隙
* (拖拽后必然如此),MAX+1 会**撞进已有间隙**里,让新条目落到用户排好的
* 中间,正是用户抱怨的"排序被打乱"。
*/
public static function nextSort(string $table, int $personId, string $groupCol = '', int $groupId = 0): int
{
if ($groupCol === '') {
$st = Database::get()->prepare(
"SELECT COALESCE(MAX(sort_order),0) FROM {$table} WHERE person_id = ?");
$st->execute([$personId]);
} else {
$st = Database::get()->prepare(
"SELECT COALESCE(MAX(sort_order),0) FROM {$table} WHERE person_id = ? AND {$groupCol} = ?");
$st->execute([$personId, $groupId]);
}
return (int)$st->fetchColumn() + self::SORT_STEP;
}
/**
* 按给定 id 顺序**整组重写**排序号(第 n 条 = n*SORT_STEP)。
*
* ⚠️ `$groupId` 非空时做**归属校验**:不属于该组(或不属于我)的 id 直接跳过 ——
* 照搬 SimpleNav 的原话:防止一个被篡改的请求把别的板块的条目拖进来。
* 这里额外再叠一层 `person_id`:否则能拖别人家的站点。
*/
public static function applySort(string $table, int $personId, array $ids,
string $groupCol = '', int $groupId = 0): int
{
$db = Database::get();
$upd = $db->prepare("UPDATE {$table} SET sort_order = ? WHERE id = ? AND person_id = ?");
$ck = null;
if ($groupCol !== '') {
$ck = $db->prepare("SELECT COUNT(*) FROM {$table} WHERE id = ? AND person_id = ? AND {$groupCol} = ?");
}
$n = 0;
$i = 0;
foreach ($ids as $rid) {
$rid = (int)$rid;
if ($rid <= 0) {
continue;
}
if ($ck !== null) {
$ck->execute([$rid, $personId, $groupId]);
if ((int)$ck->fetchColumn() === 0) {
continue; // 不属于本组 / 不是我家的 → 忽略
}
}
$i++;
$upd->execute([$i * self::SORT_STEP, $rid, $personId]);
$n++;
}
return $n;
}
/** 跨板块移动(拖到另一个板块) */
public static function moveSite(int $personId, int $id, int $toGroupId): bool
{
if (self::group($personId, $toGroupId) === null) {
return false;
}
$sort = self::nextSort('nav_sites', $personId, 'group_id', $toGroupId);
Database::get()->prepare(
'UPDATE nav_sites SET group_id = ?, sort_order = ?, updated_at = ? WHERE id = ? AND person_id = ?'
)->execute([$toGroupId, $sort, date('Y-m-d H:i:s'), $id, $personId]);
return true;
}
// ================================================================
// 短语(免登录展示页的地址后缀)
// ================================================================
/**
* 我的短语(没设过 → 空串,展示页就不可用)。
*
* ⚠️ 存在 `nav_settings` 的 `link_phrase` 键里,但**必须全局唯一** ——
* 它是免登录地址上的唯一凭据,两个人用同一个短语就等于互相串页。
* 唯一性由 `phraseTaken()` 在写入前校验(用 SQL 查,不靠应用层缓存)。
*/
public static function phrase(int $personId): string
{
return self::setting($personId, 'link_phrase', '');
}
/** 这个短语是否已被**别人**占用($except 传自己,改自己的短语时不算冲突) */
public static function phraseTaken(string $phrase, int $except = 0): bool
{
$st = Database::get()->prepare(
"SELECT COUNT(*) FROM nav_settings WHERE k = 'link_phrase' AND v = ? AND person_id <> ?");
$st->execute([$phrase, $except]);
return (int)$st->fetchColumn() > 0;
}
/**
* 设短语。返回 ['ok'=>bool, 'error'=>string]。
*
* ⚠️ 三条校验缺一不可(少了任何一条都会出问题):
* · 形式合法 → 否则地址复制粘贴时会出现"看着一样其实不同"的重码;
* · 未被占用 → 否则两个用户共用一个展示页;
* · 不是保留词 → 否则 `/nav/?nav` 这类地址会有歧义。
*/
public static function setPhrase(int $personId, string $phrase): array
{
$phrase = trim($phrase);
if ($phrase === '') {
self::setSetting($personId, 'link_phrase', '');
return ['ok' => true, 'error' => ''];
}
if (!preg_match(self::PHRASE_RE, $phrase)) {
return ['ok' => false, 'error' => '短语只能用 4~32 位的字母、数字、下划线或连字符。'];
}
if (in_array(strtolower($phrase), self::PHRASE_RESERVED, true)) {
return ['ok' => false, 'error' => '「' . $phrase . '」是系统保留词,请换一个。'];
}
if (self::phraseTaken($phrase, $personId)) {
return ['ok' => false, 'error' => '这个短语已经被别人用了,换一个吧。'];
}
self::setSetting($personId, 'link_phrase', $phrase);
return ['ok' => true, 'error' => ''];
}
/** 生成一个没被占用的随机短语(用户要的"一键换随机码") */
public static function randomPhrase(): string
{
// 去掉容易读错的 0/O/1/l/I(与 DAV 口令同一套考量)
$abc = 'abcdefghjkmnpqrstuvwxyz23456789';
for ($try = 0; $try < 30; $try++) {
$s = '';
for ($i = 0; $i < 10; $i++) {
$s .= $abc[random_int(0, strlen($abc) - 1)];
}
if (!self::phraseTaken($s)) {
return $s;
}
}
return 'nav' . bin2hex(random_bytes(6));
}
/**
* 按短语找主人(展示页用,**免登录**)。
*
* ⚠️ 返回值只给"这个短语存在吗、属于谁"—— 绝不返回任何其它身份信息。
* 这页面是公开的,多一个字段就是多一处泄露面。
*/
public static function ownerByPhrase(string $phrase): int
{
if (!preg_match(self::PHRASE_RE, $phrase)) {
return 0;
}
$st = Database::get()->prepare(
"SELECT person_id FROM nav_settings WHERE k = 'link_phrase' AND v = ? LIMIT 1");
$st->execute([$phrase]);
return (int)($st->fetchColumn() ?: 0);
}
// ================================================================
// 小工具(照搬 SimpleNav 的同名函数,去掉与家书重名的)
// ================================================================
public static function normUrl(string $url): string
{
$url = trim($url);
if ($url === '') {
return '';
}
if (!preg_match('#^https?://#i', $url)) {
$url = 'http://' . $url;
}
return mb_substr($url, 0, 500, 'UTF-8');
}
/**
* 主域名(展示用途:首字母回退、标题回退)。
*
* ⚠️ SimpleNav 原文写明:**不要拿它当图标文件名**(用 iconKey($url))——
* 同一域名下不同路径是两个不同的站点条目,用域名做键会互相覆盖。
*/
public static function domainOf(string $url): string
{
$host = parse_url(self::normUrl($url), PHP_URL_HOST);
return $host ? (string)preg_replace('/^www\./i', '', $host) : '';
}
/**
* 图标键(按 URL 取 md5)—— **必须与 SimpleNav 的 `icon_key()` 逐字一致**。
*
* 🔴🔴 v24 第 25 批 用户第 9 条修(搬运失真,导致"图标全都没有"):
*
* 原实现写成了 `md5(self::normUrl($url))` —— 看起来"合理",但 SimpleNav 的
* `icon_key()` 做的归一化比 `normUrl()` 多得多,两者算出来的键**不一样**:
* · SimpleNav:scheme/host **转小写**、**补上空路径为 `/`**、保留 port/query/fragment;
* · 原来这里:什么都不做(只用 normUrl 补 `http://` 前缀)。
* 于是 `https://otrust.ouc.edu.cn`(无路径)在两边分别得到
* `921cbff230c762f21ec5b6ffbfa1da63` 与 `0c4227cc33e0b0ab680aaf24031965f3`
* —— **只要 URL 里有一个大写字母或缺一个斜杠,文件名就对不上**。
*
* 后果(用户看到的现象):从 SimpleNav 搬过来的站点,`icon` 列里存着的文件名
* 指向的文件其实叫另一个名字,`iconUrl()` 里 `is_file()` 判假 → 返回空串 →
* 前端退化成"首字母色块",**一个图标都显示不出来**。
*
* ⚠️ 另外注意:**实际文件名是 `md5(本函数的结果)`(二次哈希)** ——
* 这是 SimpleNav 的历史遗留(只为让文件名定长),但它确实在文件名里,
* 所以搬迁 / 读写图标文件时**不要**自己再哈希或去掉这一层。
* 见 NavFetch::download_best_icon 的落盘处(它用的是 `md5($key)`)。
*/
public static function iconKey(string $url): string
{
$u = trim($url);
if ($u === '') {
return '';
}
if (!preg_match('#^https?://#i', $u)) {
$u = 'http://' . $u;
}
$p = @parse_url($u);
if (!is_array($p) || empty($p['host'])) {
return md5($u); // 解析不出来就整串兜底,至少不会跟别人撞
}
$norm = strtolower((string)($p['scheme'] ?? '')) . '://'
. strtolower((string)$p['host'])
. (empty($p['port']) ? '' : ':' . $p['port'])
. (isset($p['path']) && $p['path'] !== '' ? $p['path'] : '/')
. (isset($p['query']) ? '?' . $p['query'] : '')
. (isset($p['fragment']) ? '#' . $p['fragment'] : '');
return md5($norm);
}
/** 名称首字配色(无图标时的回退底色)—— 照搬 SimpleNav 的色板 */
public static function letterColor(string $s): string
{
$palette = ['#2F6BF7', '#5B8DEF', '#0EA5E9', '#10B981', '#F59E0B',
'#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316'];
return $palette[abs(crc32($s)) % count($palette)];
}
public static function slugify(string $s): string
{
$s = trim($s);
if (preg_match('/^[a-z0-9\-]+$/i', $s)) {
return strtolower($s);
}
return 'g' . substr(md5($s), 0, 6);
}
/** 颜色白名单:只接受 #rrggbb,其余一律回退(防手改表单塞进 CSS 注入) */
public static function safeColor(string $c, string $fallback): string
{
return preg_match('/^#[0-9a-fA-F]{6}$/', trim($c)) ? strtolower(trim($c)) : $fallback;
}
/**
* 图标显示地址(带版本号)。
*
* ⚠️ 版本号不能省:图标被重新抓取后**文件名不变**,没有版本号浏览器会一直用旧图 ——
* 用户点了"更新图标"却看不到变化,会以为功能坏了(SimpleNav 原文记过这一点)。
*/
public static function iconUrl(string $file): string
{
if ($file === '') {
return '';
}
$abs = FB_ROOT . '/' . self::ICON_DIR . '/' . $file;
if (!is_file($abs)) {
return '';
}
// 🔴 走 **nav_icon.php** 这个免登录公开出口,**不能**直接给 `uploads/…` 的地址:
// uploads/ 整个目录是 `Require all denied`(线上实测直接取会 403),
// 而展示页是免登录的、拿不到 media.php 需要的登录态。
// 详见 nav_icon.php 的文件头(两个约束为什么会撞在一起)。
// ⚠️ 管理页(登录态)也用同一条:一个图标地址两套出口,
// "管理页看得见、展示页看不见"这种半生效是最难查的。
return self::abs('/nav_icon.php') . '?f=' . rawurlencode($file) . '&v=' . filemtime($abs);
}
/**
* 把站内相对地址变成**绝对地址**(v24 第 25 批 用户第 9 条修)。
*
* 🔴🔴 为什么必须有这个函数(这是本批最要紧的一个根因):
*
* 家书的 `url()` 产出的是 `index.php?r=xxx`、`avatar_url()` 产出 `media.php?k=…`
* —— 全是**相对**地址(只有 `asset()` 是 `/assets/…` 绝对路径)。
* 它们在站点根(`https://host/index.php?...`)上没问题,
* 但**导航展示页在 `/nav/` 这个子目录下**:浏览器会把相对地址拼在**当前目录**上,
* 于是变成 `/nav/index.php?r=…`、`/nav/media.php?k=…` —— **线上实测全 404**。
*
* 症状正是用户报的那三条一起出现(同一个根因):
* · 展示页头像不显示(`/nav/media.php?k=avatar.jpg` → 404)
* · 点「登录」404(`/nav/index.php?r=auth/login` → 404)
* · 搜索联想请求打到 `/nav/…`(同样 404,下拉永远空)
*
* 修法:**凡是展示页要输出的自引用地址,一律经这里拼上 base_url**。
* ⚠️ 只在**免登录展示页**用得到(它是唯一不在站点根下的页面)——
* 不要拿它去替换全站的 `url()`:那会把所有站内链接都变成绝对地址,
* `?r=` 那套路由本来就不需要域名,改了反而多一处配置依赖。
*
* @param string $path 以 `/` 开头的站内路径,或 `index.php?r=…` 这种相对串
*/
public static function abs(string $path): string
{
$path = '/' . ltrim($path, '/');
/* 🔴 优先用**当前请求的 Host**,不要写死配置里的 base_url。
*
* 为什么(2026-10-07 第 25 批实测):本地真机探针起的是**随机端口**的
* `php -S 127.0.0.1:<port>`,而 `config.php` 的 `base_url` 写的是
* `http://127.0.0.1:8791`。展示页把 CSS 拼成 `…:8791/assets/nav-show.css`
* → 与探针那个端口对不上 → **样式整份取不到** →
* 症状是"登录按钮落到左边、头像判据也跟着偏",看起来像**布局坏了**,
* 其实是"资源地址指到了另一个端口"(最难查的一类:页面 200、HTML 全对)。
* ⇒ 绝对化要以"用户实际访问的那个 host"为准 —— 这也是**唯一正确**的口径:
* 线上有多个入口(域名 / IP / 宝塔测试域名)时,写死 base_url 会把
* 另外那些入口的静态资源全指到 base_url 那个域上去。
*
* ⚠️ 但 Host 头是**客户端可控**的(Host 注入)—— 所以只接受**形状合法**的
* host:字母数字点横线、可带端口。不合法就退回 base_url。
*/
$host = (string)($_SERVER['HTTP_HOST'] ?? '');
if ($host !== '' && preg_match('/^[A-Za-z0-9.\-]+(:\d+)?$/', $host)) {
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'
|| (int)($_SERVER['SERVER_PORT'] ?? 0) === 443;
return ($https ? 'https://' : 'http://') . $host . $path;
}
$base = rtrim((string)fb_config('base_url', ''), '/');
if ($base === '') {
// 没配 base_url(极少数自建环境)→ 退化成"从站点根算"的绝对路径。
// 仍然比相对地址安全:相对地址在 /nav/ 下必错,而 /index.php 至少指对了。
return $path;
}
return $base . $path;
}
public static function deleteIconFile(string $file): void
{
if ($file === '' || strpos($file, '/') !== false || strpos($file, '..') !== false) {
return;
}
$abs = FB_ROOT . '/' . self::ICON_DIR . '/' . $file;
if (is_file($abs)) {
@unlink($abs);
}
}
public static function iconDirWritable(): bool
{
$dir = FB_ROOT . '/' . self::ICON_DIR;
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
return is_dir($dir) && is_writable($dir);
}
/** 打开方式:_blank(新标签页,默认)或 _self */
public static function openTarget(int $personId): string
{
return self::setting($personId, 'open_blank', '1') === '1' ? '_blank' : '_self';
}
/** 联想词按键(照搬 SimpleNav 的白名单回退) */
public static function suggestKeys(int $personId): string
{
$v = self::setting($personId, 'suggest_keys', 'both');
return in_array($v, ['arrows', 'punct', 'both'], true) ? $v : 'both';
}
/** 合集布局(tabs / flat) */
public static function collectionsLayout(int $personId): string
{
return self::setting($personId, 'collections_layout', 'tabs') === 'flat' ? 'flat' : 'tabs';
}
/**
* **首次进入时**给这个人一份默认导航(照搬 SimpleNav 的种子数据,精选了一部分)。
*
* ⚠️ 只在"这个人一条板块都没有"时调 —— 否则用户把板块删光后,
* 下一次进页面又会冒出默认的那几块("我删了它怎么又回来了")。
*/
public static function ensureSeed(int $personId): void
{
$st = Database::get()->prepare('SELECT COUNT(*) FROM nav_groups WHERE person_id = ?');
$st->execute([$personId]);
if ((int)$st->fetchColumn() > 0) {
return;
}
$db = Database::get();
$now = date('Y-m-d H:i:s');
$gs = $db->prepare(
'INSERT INTO nav_groups (person_id, name, slug, display, sort_order, is_visible) VALUES (?,?,?,?,?,1)');
$ss = $db->prepare(
'INSERT INTO nav_sites (person_id, group_id, url, name, short_name, icon, icon_custom,
sort_order, is_visible, created_at, updated_at) VALUES (?,?,?,?,?,\'\',0,?,1,?,?)');
$i = 0;
foreach (self::SEED as $row) {
[$gname, $display, $sites] = $row;
$i++;
$gs->execute([$personId, $gname, self::slugify($gname), $display, $i * self::SORT_STEP]);
$gid = (int)$db->lastInsertId();
$j = 0;
foreach ($sites as $site) {
$j++;
$ss->execute([$personId, $gid, $site[1], $site[0], $site[0],
$j * self::SORT_STEP, $now, $now]);
}
}
// 默认搜索引擎(照搬 SimpleNav 的引擎表:必应/百度启用,其余列出但未启用)
$es = $db->prepare(
'INSERT INTO nav_engines (person_id, name, slug, search_url, param, suggest_url,
suggest_type, color, is_active, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?)');
$k = 0;
foreach (self::ENGINE_SEED as $e) {
$k++;
$es->execute([$personId, $e[0], $e[1], $e[2], $e[3], $e[4], $e[5], $e[6], $e[7],
$k * self::SORT_STEP]);
}
}
/** 默认板块(照搬 SimpleNav 的 seed_groups_and_sites,去掉纯广告位,保留通用工具) */
public const SEED = [
['常用', 'grid', [
['GitHub', 'https://github.com/'],
['阿里云', 'https://www.aliyun.com/'],
['知乎', 'https://www.zhihu.com/'],
['豆瓣', 'https://www.douban.com/'],
['少数派', 'https://sspai.com/'],
['bilibili', 'https://www.bilibili.com/'],
]],
['工具', 'grid', [
['腾讯文档', 'https://docs.qq.com/'],
['飞书', 'https://www.feishu.cn/'],
['Gitee', 'https://gitee.com/'],
['百度网盘', 'https://pan.baidu.com/'],
['draw.io', 'https://app.diagrams.net/'],
['Bitwarden', 'https://bitwarden.com/'],
['高德地图', 'https://gaode.com/'],
['微信读书', 'https://weread.qq.com/'],
]],
['常用软件', 'tabs', [
['WPS', 'https://www.wps.cn/'],
['VS Code', 'https://code.visualstudio.com/'],
['7-Zip', 'https://sparanoid.com/lab/7z/'],
['Everything', 'https://www.voidtools.com/zh-cn/'],
['DBeaver', 'https://github.com/dbeaver/dbeaver'],
['VLC', 'https://www.videolan.org/vlc/'],
['Zotero', 'https://www.zotero.org/'],
]],
['图片资源', 'tabs', [
['图标库', 'https://www.iconfont.cn/'],
['插画库', 'https://undraw.co/illustrations'],
['Pixabay', 'https://pixabay.com/zh/'],
['Pexels', 'https://www.pexels.com/'],
]],
['科研论文', 'tabs', [
['DeepL翻译', 'https://www.deepl.com/'],
['Semantic Scholar', 'https://www.semanticscholar.org/'],
['LaTeX Table', 'https://www.tablesgenerator.com/'],
['学堂在线', 'https://www.xuetangx.com/'],
]],
];
/** 默认搜索引擎(照搬 SimpleNav 的 seed_engines) */
public const ENGINE_SEED = [
// 名称, slug, 搜索地址, 参数, 联想地址, 联想类型, 颜色, 是否启用
//
// 🔴 v24 第 26 批 用户第 4 条(后半):「**可以内置几个,让用户选择**」。
// 原来只启用必应/百度两档,用户根本看不出"可选"这件事(列表里另外 4 个
// 是以"未启用"状态躺着的,展示页的标签条只渲染启用的)。
// ⇒ 把**实测可用的三档**都默认启用:必应 / 百度 / 360。
// 谷歌 / DuckGo 保持未启用 —— 它们在**国内网络下连不上**
// (见 nav_suggest.php 头部那段线上实测:连接超时),
// 默认启用只会让用户点了发现"搜不动",比不提供更差。
// 搜狗保持未启用:它的联想域名已不解析(实测 0 字节)。
// ⚠️ 这三条实测结论来自 nav_suggest.php 的注释,不要凭印象改动。
['必应', 'bing', 'https://cn.bing.com/search', 'q',
'https://api.bing.com/osjson.aspx?query={q}', 'bing_osjson', '#60A504', 1],
['百度', 'baidu', 'https://www.baidu.com/s', 'wd',
'https://www.baidu.com/sugrec?prod=pc&wd={q}', 'baidu_sugrec', '#2100E0', 1],
['360', 'so360', 'https://www.so.com/s', 'q',
'https://sug.so.360.cn/suggest?encodein=utf-8&encodeout=utf-8&format=json&word={q}',
'so360', '#1BB04A', 1],
['谷歌', 'google', 'https://www.google.com/search', 'q', '', '', '#3B83FA', 0],
['搜狗', 'sogou', 'https://www.sogou.com/web', 'query', '', '', '#FE6B14', 0],
['DuckGo', 'ddg', 'https://duckduckgo.com/', 'q', '', '', '#DE5833', 0],
];
/**
* 把**缺失的内置引擎**补给这个人(v24 第 26 批 用户第 4 条)。
*
* 为什么需要它:`ensureSeed()` 只在"一条引擎都没有"时才灌种子
* (它的判据是 `nav_groups` 是否为空)—— 于是**老用户**(在更新前就建过导航的人)
* 永远拿不到后加的内置引擎:他们的库里只有当年那几条。
* 用户说"内置几个让用户选择",对老用户就等于"一个都没有"。
*
* ⚠️ 按 **slug** 判重(不是 name):用户可能把"百度"改名成"度娘",
* 若按名字判重会再灌一条重复的 baidu。
* ⚠️ 只补**缺的**、绝不改动已有的(用户可能改过 URL / 颜色 / 启用状态,
* 覆盖回去等于"我帮你改好了" —— 那种"好意"最难查)。
*
* @return int 新补了几条
*/
public static function addMissingEngines(int $personId): int
{
$db = Database::get();
$st = $db->prepare('SELECT slug FROM nav_engines WHERE person_id = ?');
$st->execute([$personId]);
$have = [];
foreach ($st->fetchAll() as $r) {
$have[(string)$r['slug']] = true;
}
$sort = (int)$db->query('SELECT COALESCE(MAX(sort_order),0) FROM nav_engines WHERE person_id = '
. (int)$personId)->fetchColumn();
$ins = $db->prepare(
'INSERT INTO nav_engines (person_id, name, slug, search_url, param, suggest_url,
suggest_type, color, is_active, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?)');
$n = 0;
foreach (self::ENGINE_SEED as $e) {
if (isset($have[(string)$e[1]])) {
continue;
}
$sort += self::SORT_STEP;
// ⚠️ 补进来的按种子的 `is_active` 走(必应/百度/360 = 1)。
// "老用户库里已经有的那几条"保持原状 —— 不在这里动它们。
$ins->execute([$personId, $e[0], $e[1], $e[2], $e[3], $e[4], $e[5], $e[6], $e[7], $sort]);
$n++;
}
return $n;
}
}