diff --git a/app/Nav.php b/app/Nav.php index b9dfde3..2d501e9 100644 --- a/app/Nav.php +++ b/app/Nav.php @@ -220,6 +220,13 @@ final class Nav * * ⚠️ `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 { @@ -235,23 +242,87 @@ final class Nav $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 (?,?,?,?,?,\'\',0,?,?,?,?)' - )->execute([$personId, $gid, $url, $name, $short, $sort, $vis, $now, $now]); + 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); diff --git a/app/NavFetch.php b/app/NavFetch.php index 20c1387..c1906ef 100644 --- a/app/NavFetch.php +++ b/app/NavFetch.php @@ -84,10 +84,23 @@ final class NavFetch * * ⚠️ 落盘后调用方**必须**把 `nav_sites.icon_custom` 置 1 —— * 否则下一次「全部更新」会把用户精心挑的图标冲掉(SimpleNav 原文明确要求)。 + * + * 🔴 工单 #16:这里**不能**再用 `icon_key($url)`(= md5(网址))当文件名。 + * 原项目 SimpleNav 是**单用户**系统,"一个网址一个图标文件"没问题; + * 家书是**多用户**的,两个人给同一个网址上传不同图标时, + * md5(url) 会让后传的**直接覆盖**先传的 —— A 在自己导航里看到 B 的图标, + * 而且 A 那条的 `icon` 字段还指向这个文件,等于两个人的图标互相串。 + * + * 改用 `random_bytes` 生成**每次唯一**的文件名(与 `Nav::copyIconFile()` + * 同一套做法):上传一次一个文件,换图标就换文件名, + * 旧的由调用方显式删除(`Nav::deleteIconFile`,且只在没人复用时删)。 */ - public static function saveUploaded(string $binary, string $url): string + public static function saveUploaded(string $binary, string $url = ''): string { - return self::save_custom_icon($binary, self::icon_key($url)); + // $url 保留在签名里只为兼容旧调用;不再参与文件命名(见上面说明)。 + // save_custom_icon 内部取 md5($key) 当文件名 ⇒ 传一个随机键即得唯一文件名 + // (结果是 32 位十六进制,正好符合 nav_icon.php 的白名单)。 + return self::save_custom_icon($binary, bin2hex(random_bytes(16))); } /** curl 抓取(带 UA、超时、跟随跳转) */ @@ -155,14 +168,14 @@ final class NavFetch $url = Nav::normUrl($url); $out = ['title' => '', 'name' => '', 'icon' => '', 'icons' => [], 'final_url' => $url]; - $resp = http_get($url); + $resp = self::http_get($url); if ($resp === null) { - $out['icons'] = array_merge(third_party_icon_urls($url), default_icon_candidates($url)); + $out['icons'] = array_merge(self::third_party_icon_urls($url), self::default_icon_candidates($url)); $out['icon'] = $out['icons'][0] ?? ''; return $out; } - $html = ensure_utf8((string)$resp['body']); + $html = self::ensure_utf8((string)$resp['body']); $final = $resp['url'] !== '' ? $resp['url'] : $url; $out['final_url'] = $final; @@ -170,7 +183,7 @@ final class NavFetch if (preg_match('#]*>(.*?)#is', $html, $m)) { $out['title'] = trim(preg_replace('/\s+/u', ' ', html_entity_decode($m[1], ENT_QUOTES, 'UTF-8'))); } - $out['name'] = meta_content($html, 'og:site_name'); + $out['name'] = self::meta_content($html, 'og:site_name'); if ($out['name'] === '' && $out['title'] !== '') { // 用标题的首段(按分隔符切分)作为名称 $seg = preg_split('#[\|\-_—·::]#u', $out['title']); @@ -183,20 +196,20 @@ final class NavFetch // 3. ,按 sizes 降序(SVG 视为最高清) // 4. 联网搜索:第三方图标聚合服务(仅当上面都没拿到高清图时才会用到) // 5. 兜底:站点自身 /favicon.ico - [$links, $manifestUrl] = parse_icon_links($html, $final); + [$links, $manifestUrl] = self::parse_icon_links($html, $final); $icons = []; if ($manifestUrl !== '') { - foreach (manifest_icons($manifestUrl) as $u) { + foreach (self::manifest_icons($manifestUrl) as $u) { $icons[] = $u; } } foreach ($links as $u) { $icons[] = $u; } - foreach (third_party_icon_urls($final) as $u) { + foreach (self::third_party_icon_urls($final) as $u) { $icons[] = $u; } - foreach (default_icon_candidates($final) as $u) { + foreach (self::default_icon_candidates($final) as $u) { $icons[] = $u; } @@ -274,12 +287,12 @@ final class NavFetch if (preg_match_all('#]*>#i', $html, $tags)) { foreach ($tags[0] as $tag) { - $relv = attr_value($tag, 'rel'); + $relv = self::attr_value($tag, 'rel'); if ($relv === '') { continue; } $relv = strtolower($relv); - $href = html_entity_decode(attr_value($tag, 'href'), ENT_QUOTES, 'UTF-8'); + $href = html_entity_decode(self::attr_value($tag, 'href'), ENT_QUOTES, 'UTF-8'); if ($href === '') { continue; } @@ -295,7 +308,7 @@ final class NavFetch continue; } $size = 0; - $raw = attr_value($tag, 'sizes'); + $raw = self::attr_value($tag, 'sizes'); if ($raw !== '') { foreach (preg_split('/[\s,]+/', trim($raw)) ?: [] as $one) { if (stripos($one, 'any') !== false) { @@ -306,7 +319,7 @@ final class NavFetch } } // SVG 是矢量,视为最高清 - if (preg_match('#\.svg(\?|$)#i', $href) || stripos(attr_value($tag, 'type'), 'svg') !== false) { + if (preg_match('#\.svg(\?|$)#i', $href) || stripos(self::attr_value($tag, 'type'), 'svg') !== false) { $size = max($size, 512); } $rel[] = ['size' => $size, 'url' => $href]; @@ -316,9 +329,9 @@ final class NavFetch usort($rel, static function ($a, $b) { return $b['size'] <=> $a['size']; }); $list = []; - foreach ($apple as $h) { $list[] = absolutize($h, $base); } - foreach ($rel as $r) { $list[] = absolutize($r['url'], $base); } - return [$list, $manifest === '' ? '' : absolutize($manifest, $base)]; + foreach ($apple as $h) { $list[] = self::absolutize($h, $base); } + foreach ($rel as $r) { $list[] = self::absolutize($r['url'], $base); } + return [$list, $manifest === '' ? '' : self::absolutize($manifest, $base)]; } /** 读取 web app manifest,返回其中图标(按尺寸降序) */ @@ -326,7 +339,7 @@ final class NavFetch /** 读取 web app manifest,返回其中图标(按尺寸降序) */ private static function manifest_icons(string $manifestUrl): array { - $r = http_get($manifestUrl, 200000, 6); + $r = self::http_get($manifestUrl, 200000, 6); if ($r === null) { return []; } @@ -350,7 +363,7 @@ final class NavFetch } } } - $list[] = ['size' => $size, 'url' => absolutize($ic['src'], $base)]; + $list[] = ['size' => $size, 'url' => self::absolutize($ic['src'], $base)]; } usort($list, static function ($a, $b) { return $b['size'] <=> $a['size']; }); return array_values(array_unique(array_column($list, 'url'))); @@ -488,13 +501,13 @@ final class NavFetch break; } $tried++; - $r = grab_icon($u); + $r = self::grab_icon($u); if ($r === null) { continue; } // 第三方聚合服务在「查不到图标」时会返回一块浅灰底 + 单个深灰字母的生成图, // 那还不如本地首字母块好看 —— 识别出来就丢弃,继续回退到站点自己的图标。 - if ($r['ext'] === 'png' && is_third_party_icon($u) && looks_generated_tile($r['tmp'])) { + if ($r['ext'] === 'png' && self::is_third_party_icon($u) && self::looks_generated_tile($r['tmp'])) { @unlink($r['tmp']); continue; } @@ -601,7 +614,7 @@ final class NavFetch */ private static function grab_icon(string $iconUrl): ?array { - $resp = http_get($iconUrl, 2 * 1024 * 1024, 5); + $resp = self::http_get($iconUrl, 2 * 1024 * 1024, 5); if ($resp === null) { return null; } diff --git a/app/bootstrap.php b/app/bootstrap.php index fc2785e..c0e9679 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -23,6 +23,26 @@ define('FB_ROOT', dirname(__DIR__)); define('FB_APP', __DIR__); define('FB_UPLOADS', FB_ROOT . '/uploads'); +/** + * 导航图标统一落盘的边长(正方形,只缩不放、透明留白居中)。 + * + * 🔴 工单 #16:这个常量从 SimpleNav 搬迁时**漏搬了定义**。 + * `NavFetch::grab_icon()` 与 `NavFetch::save_custom_icon()` 两处都在用它 + * (`imagecreatetruecolor($store, $store)`),但全仓没有 `define`。 + * PHP 8 下访问未定义常量抛 `Error: Undefined constant "ICON_STORE"` + * ⇒ 走到 GD 位图分支就 500。 + * + * 为什么之前一直没暴露:`saveUploaded()` 是**死代码**(只有定义、没有调用点, + * 因为表单里没有上传入口 = 工单 #16 本身);而抓取路径里 SVG 分支在前、 + * 很多站点 favicon 恰好是 SVG,绕过了 GD。只要有位图 favicon 就会崩。 + * + * 取值沿用 SimpleNav 原文:128 —— 高清屏下按 45~64px 显示仍然锐利, + * 同时 128×128 的 PNG 只有几 KB,20 个格子也不心疼。 + */ +if (!defined('ICON_STORE')) { + define('ICON_STORE', 128); +} + // ---------------- 配置 ---------------- $configFile = FB_ROOT . '/config.php'; if (!is_file($configFile)) { diff --git a/assets/css/app.css b/assets/css/app.css index ce44cfb..a700d25 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -4982,6 +4982,31 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b display: inline-flex; align-items: center; justify-content: center; color: #fff; font-size: 11px; border: 0; } +/* 大号字母块:图标选择器里的预览位(与列表里的 20px 区分开) */ +.nav-ico-lg { width: 40px; height: 40px; font-size: 18px; border-radius: 8px; } + +/* ------------------------------------------------------------ + 站点表单:自定义图标选择器(工单 #16) + 左侧预览、右侧文件选择 + 说明。预览用 40px 见方, + 和列表里的 20px 小图标区分开,让用户能看清传的是什么。 + ------------------------------------------------------------ */ +.nav-icon-pick { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.nav-icon-prev { + flex: 0 0 auto; width: 44px; height: 44px; + display: flex; align-items: center; justify-content: center; + border: 1px solid var(--line-2); border-radius: 10px; background: #fff; + overflow: hidden; +} +.nav-icon-prev img { width: 40px; height: 40px; object-fit: contain; display: block; } +.nav-icon-ctl { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; } +/* 原生 file 控件隐藏,用