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

734 lines
31 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'] ?? ''));
}
// ================================================================
// 搜索引擎
// ================================================================
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 一致) */
public static function iconKey(string $url): string
{
return md5(self::normUrl($url));
}
/** 名称首字配色(无图标时的回退底色)—— 照搬 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 '';
}
return url('/' . self::ICON_URL . '/' . rawurlencode($file)) . '?v=' . filemtime($abs);
}
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, 搜索地址, 参数, 联想地址, 联想类型, 颜色, 是否启用
['必应', '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],
['谷歌', 'google', 'https://www.google.com/search', 'q', '', '', '#3B83FA', 0],
['搜狗', 'sogou', 'https://www.sogou.com/web', 'query', '', '', '#FE6B14', 0],
['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', 0],
['DuckGo', 'ddg', 'https://duckduckgo.com/', 'q', '', '', '#DE5833', 0],
];
}