Files

1143 lines
54 KiB
PHP
Raw Permalink Normal View History

<?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:「全部更新」抓图标时**不动**
* 用户自己传的图标。漏掉这一条,用户精心挑的图标会被下一次批量更新冲掉。
*
* 🔴 工单 #16:新增两个可选键
* · `icon` —— 自定义图标文件名(已在 ICON_DIR 里落好盘);空串 = 不改
* · `icon_clear` —— true = 清掉自定义图标,回到"抓取 / 字母块"
* ⚠️ 两者语义是**互斥**的,同时给时以 `icon` 为准(清完又传没有意义)。
* ⚠️ 只有显式给了这两个键之一才动 icon 列 —— 否则 `site_save` 每次
* 普通保存都会把用户之前设的图标洗掉(那正是"改一次名字图标就没了")。
*/
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');
// ---- 自定义图标:三态(不动 / 设成新文件 / 清空)----
$hasIconKey = array_key_exists('icon', $d) || !empty($d['icon_clear']);
$newIcon = '';
$iconCustom = 0;
if (array_key_exists('icon', $d) && (string)$d['icon'] !== '') {
$cand = (string)$d['icon'];
// 只认纯文件名(与 iconUrl / deleteIconFile / copyIconFile 同一套判据)
if (strpos($cand, '/') === false && strpos($cand, '\\') === false
&& strpos($cand, '..') === false) {
$newIcon = $cand;
$iconCustom = 1;
}
}
if ($id > 0) {
if ($hasIconKey && $newIcon !== '') {
// 换图标:先记下旧文件名,落库之后再删(删早了万一 UPDATE 失败就丢图)
$old = (string)(self::site($personId, $id)['icon'] ?? '');
Database::get()->prepare(
'UPDATE nav_sites SET group_id = ?, url = ?, name = ?, short_name = ?,
is_visible = ?, icon = ?, icon_custom = 1, updated_at = ?
WHERE id = ? AND person_id = ?'
)->execute([$gid, $url, $name, $short, $vis, $newIcon, $now, $id, $personId]);
if ($old !== '' && $old !== $newIcon && !self::iconFileInUse($old, $id)) {
self::deleteIconFile($old);
}
return $id;
}
if ($hasIconKey && $newIcon === '') {
// 清空自定义图标:icon 也清掉,让 iconUrl() 回落到字母块 / 下次抓取
$old = (string)(self::site($personId, $id)['icon'] ?? '');
Database::get()->prepare(
'UPDATE nav_sites SET group_id = ?, url = ?, name = ?, short_name = ?,
is_visible = ?, icon = \'\', icon_custom = 0, updated_at = ?
WHERE id = ? AND person_id = ?'
)->execute([$gid, $url, $name, $short, $vis, $now, $id, $personId]);
if ($old !== '' && !self::iconFileInUse($old, $id)) {
self::deleteIconFile($old);
}
return $id;
}
// 普通的"改名字/改板块":icon 列一个字都不碰
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 (?,?,?,?,?,?,?,?,?,?,?)'
)->execute([$personId, $gid, $url, $name, $short, $newIcon, $iconCustom,
$sort, $vis, $now, $now]);
return (int)Database::get()->lastInsertId();
}
/**
* 这个图标文件还有没有**别人**在用(除 $exceptSiteId 之外)。
*
* 🔴 为什么必须查:图标文件在 SimpleNav 里是"一个网址一个文件"(md5(url)),
* 家书搬过来后仍然沿用这个规则给**抓取**的图标命名 ⇒ 多用户可能指向同一个文件。
* 换图标时若无脑 unlink,会把别人的图标一起删掉(对方列表里当场变成破图)。
* ⇒ 删之前先数一下还有几条记录指向它,只剩自己那条才删。
*/
public static function iconFileInUse(string $file, int $exceptSiteId = 0): bool
{
if ($file === '') {
return false;
}
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM nav_sites WHERE icon = ? AND id <> ?'
);
$st->execute([$file, $exceptSiteId]);
return (int)$st->fetchColumn() > 0;
}
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],
];
/**
* **联想接口库**(v28 用户第 10 条)。
*
* 用户原话:「导航功能,联想接口还是不能用啊,需要你补充几个进去,让用户选择。
* 搜索引擎列表,联想接口,是**对所有用户可见**的。」
*
* ⇒ 提供一份**内置的、大家都能看到**的联想接口清单(不是每人各存一份)。
* 用户在「搜索引擎设置」里点一下就能把某个接口用到自己的引擎上。
*
* ⚠️ 这份清单里的每一条都必须**实测可用**(下方注释标了来源)。
* 国内网络下不可达的(谷歌 / DuckDuckGo)**放进去也没用** ——
* 用户点了发现永远空下拉,比不提供更差。所以只列走通的。
* ⚠️ `type` 决定解析方式(见 nav_suggest.php 的 nav_parse_suggest),
* 必须是那边认得的取值,加新类型要**两处一起改**。
*/
public const SUGGEST_LIBRARY = [
// [显示名, 联想地址模板, 解析类型, 说明]
['百度', 'https://www.baidu.com/sugrec?prod=pc&wd={q}', 'baidu_sugrec',
'实测最快最稳(约 0.1 秒)'],
['360', 'https://sug.so.360.cn/suggest?encodein=utf-8&encodeout=utf-8&format=json&word={q}',
'so360', '速度次之(约 0.2 秒)'],
['必应', 'https://api.bing.com/osjson.aspx?query={q}', 'bing_osjson',
'可用但偏慢(0.5~1.6 秒)'],
['搜狗', 'https://suggestion.sogou.com/suggnew/ajajjson?key={q}&type=web', 'sogou',
'备用(域名时好时坏)'],
['知乎', 'https://www.zhihu.com/api/v4/search/suggest?q={q}&token=', 'zhihu',
'内容向站点,适合当第二来源'],
['B站', 'https://s.search.bilibili.com/main/suggest?term={q}', 'bili',
'内容向站点'],
];
/**
* 某个人现在**能选**的联想接口 = 内置库里的全部(对所有用户可见)。
* @return array<int,array{name:string,url:string,type:string,note:string}>
*/
public static function suggestLibrary(): array
{
$out = [];
foreach (self::SUGGEST_LIBRARY as $row) {
$out[] = ['name' => $row[0], 'url' => $row[1], 'type' => $row[2], 'note' => $row[3]];
}
return $out;
}
/**
* 把**缺失的内置引擎**补给这个人(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;
}
}