'我的导航', '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], ]; }