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`
This commit is contained in:
+734
@@ -0,0 +1,734 @@
|
||||
<?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],
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user