diff --git a/.htaccess b/.htaccess index bb5d694..10f7542 100644 --- a/.htaccess +++ b/.htaccess @@ -6,3 +6,35 @@ # 关闭目录浏览 Options -Indexes + +# 根目录静态资源(favicon / apple-touch-icon / webmanifest)也走「长缓存 + 版本号」 +# 注意:assets/ 下的资源由 assets/.htaccess 负责,这里只管根目录这几个文件。 + + + Header set Cache-Control "public, max-age=31536000, immutable" + + + +# --------------------------------------------------------------------------- +# 绕开「服务器侧整页缓存」 +# +# 背景(2026-09-28 线上实测):服务器装了宝塔面板的「网站加速」,那是一层基于 +# memcached 的整页缓存(脚本 /www/server/speed/httpd_speed.lua,站点配置 +# /www/server/speed/config.lua 里 n.yangyq.net 这节,expire=30 秒)。它的缓存键 +# 按 host:port + 完整 URI 计算,只缓存 GET 的 text/html。 +# +# 关键坑:它**读不到 FastCGI 后端返回的 Cache-Control**(实测存下来的副本里 +# 根本没有这个头),所以 PHP 里发的 no-store / no-cache 对它无效 —— 会出现 +# 「后台改完,前台刷新还是旧的」,而且因为每次 MISS 都会重新写入,旧页面窗口 +# 还会被后续访问不断续期(滑动 30 秒)。 +# +# 它唯一认的「别缓存我」信号是**请求** cookie `X_CACHE=1`(它本来用于标记已登录 +# 用户)。这里就在请求侧直接注入该标记:edit 处理「已经有 Cookie」的情况, +# setifempty 处理「完全没有 Cookie」的首次访问。 +# +# 若哪天不需要了(例如该站点已关闭「网站加速」),整段删掉即可。 +# --------------------------------------------------------------------------- + + RequestHeader edit Cookie "^(.*)$" "$1; X_CACHE=1" + RequestHeader setifempty Cookie "X_CACHE=1" + diff --git a/README.md b/README.md index 627be07..241ed45 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,14 @@ - **轻量**:纯 PHP + SQLite,不依赖 MySQL / 框架 / Composer;前端无 Bootstrap / jQuery,原生 CSS + JS。 - **个人向**:单管理员,页面就是「你自己常用网站的起始页」。 -- **搜索引擎可切换 + 联想下拉**:后台勾选喜欢的搜索引擎即可显示在顶部;输入时下拉联想词由服务端代理(规避跨域)。 -- **添加即补全**:填网址自动抓取网站名称、图标(图标下载并规范化后存到服务器本地)。 +- **搜索引擎可切换 + 联想下拉**:后台勾选喜欢的搜索引擎即可显示在顶部;输入时下拉联想词由服务端代理(规避跨域)。后台的「显示/隐藏」按钮点一下**原地生效、不跳页**,且无 JS 时也能正常提交。 +- **添加即补全**:填网址后先访问该网址拿到标题,再按「高清优先」的顺序去网上找相关图标(`manifest` 512 → `apple-touch-icon` → `` → 第三方聚合服务),下载后统一规范化为 **128×128 PNG** 存到服务器本地。 +- **图标可自定义,且自定义后不再被覆盖**:可为任一网站上传图标或直接填图标地址;一旦自定义,该网站的图标会被标记保护,「一键全部更新」只刷新它的名称、跳过图标。 - **显示简称**:每个网站有「名称(自动)」和「简称(手动)」两个字段,页面展示你自定义的简称。 - **自定义排序**:网站 / 板块 / 搜索引擎都可上移下移排序。 -- **一键全部更新**:批量刷新所有网站的图标与名称,**不会改动你自定义的简称**。 +- **一键全部更新**:批量刷新所有网站的图标与名称,**不会改动你自定义的简称,也不会覆盖你自定义的图标**。 - **合集标签页**:把「合集」做成标签页,点标签平铺该合集下的全部链接。 +- **前后台永远一致**:所有动态页面都发 `Cache-Control: no-store, no-cache`,静态资源走「长缓存 + 版本号」(文件名不变、URL 带 `?v=mtime-size`)。 ## 页面结构(复刻 n.yangyq.net 风格) @@ -26,15 +28,17 @@ SimpleNav/ ├─ index.php 前台首页 ├─ suggest.php 搜索联想代理(多引擎,服务端请求) ├─ login.php / logout.php 管理员登录 / 首次初始化 -├─ db.php 数据库初始化(建表 + 默认数据) +├─ db.php 数据库初始化(建表 + 默认数据 + 幂等列迁移) ├─ config.php 基础配置 -├─ functions.php 公共函数(抓取元信息、下载图标等) +├─ functions.php 公共函数(抓取元信息、下载图标、资源版本号、不缓存响应头) +├─ .htaccess 根目录安全规则(禁止访问 data/ 与 .git/) ├─ admin/ 后台(网站 / 板块 / 搜索引擎 / 设置) │ ├─ index.php 后台页面 -│ ├─ actions.php 增删改 / 排序处理 +│ ├─ actions.php 增删改 / 排序处理(支持 AJAX 原地切换) │ ├─ fetch_meta.php 添加网站时自动抓取名称与图标 │ └─ update_all.php 一键“全部更新”(分批) -├─ assets/ CSS / JS / 图标 +├─ assets/ CSS / JS / 图标 / logo +│ └─ .htaccess 静态资源长缓存(配合 ?v= 版本号) ├─ uploads/icons/ 抓取到的网站图标(需可写) └─ data/ SQLite 数据库(需可写,禁止 Web 访问) ``` @@ -43,10 +47,16 @@ SimpleNav/ 1. 上传全部文件到站点目录(如 `/www/wwwroot/n.yangyq.net/simplenav`)。 2. 确保 web 用户对 `data/` 与 `uploads/icons/` 有写权限。 -3. 浏览器打开站点 → 首次访问进入「初始化管理员」,创建账号后即可在后台管理。 -4. 建议在 nginx server{} 中加入 `deploy/nginx-simplenav-deny-data.conf` 里的规则(禁止访问 `data/`)。 +3. 服务器需开启 PHP 的 **GD 扩展**(图标规范化要用)与 `curl`。 +4. 浏览器打开站点 → 首次访问进入「初始化管理员」,创建账号后即可在后台管理。 +5. 若服务器是 **Apache**(宝塔默认可能装 Apache 或 Nginx),`data/` 的访问拦截由仓库自带的 + `data/.htaccess` + 根目录 `.htaccess` 完成,无需改 vhost;若是 Nginx,请把 + `nginx-simplenav-deny-data.conf`(见本机 `_tooling/simplenav-deploy/`)里的规则加进 `server{}`。 -服务器上创建最小权限部署账号:以 root 运行 `deploy/setup-simplenav-user.sh`。 +服务器上创建最小权限部署账号:以 root 运行 `setup-simplenav-user.sh`(同样在 `_tooling/simplenav-deploy/`)。 + +> 需要额外说明的是:**服务器上若装了宝塔「网站加速」之类的页面缓存插件,本站也不需要为它做任何特殊配置**。 +> 动态页发的 `Cache-Control: ... no-cache ...` 会被这类插件视为「不缓存」而直接跳过写入。 ## 搜索引擎联想接口说明 diff --git a/admin/_auth.php b/admin/_auth.php index 20c788e..ae66b94 100644 --- a/admin/_auth.php +++ b/admin/_auth.php @@ -70,8 +70,10 @@ function admin_layout(string $active, string $title, string $body): void <?= e($title) ?> · <?= e(site_name()) ?> 后台 - - + + + +
@@ -87,7 +89,7 @@ function admin_layout(string $active, string $title, string $body): void
- + $m, 't' => $t]; } function post_id(): int { return (int)($_POST['id'] ?? 0); } +/** AJAX 直出 JSON 并结束 */ +function json_out(array $data): void +{ + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_UNESCAPED_UNICODE); + exit; +} + +/** 读取某表单状态字段的当前值(用于把最新状态回给前端) */ +function state_of(string $table, int $id, string $col): int +{ + $st = db()->prepare("SELECT {$col} FROM {$table} WHERE id=?"); + $st->execute([$id]); + return (int)$st->fetchColumn(); +} + +$ajax = null; // 需要在 AJAX 模式下返回的附加数据 + try { switch ($action) { @@ -31,22 +53,62 @@ try { $url = norm_url((string)($_POST['url'] ?? '')); $name = trim((string)($_POST['name'] ?? '')); $short = trim((string)($_POST['short_name'] ?? '')); - $icon = trim((string)($_POST['icon'] ?? '')); $visible = isset($_POST['is_visible']) ? 1 : 0; if ($url === '' || $gid <= 0) { flash('网址和板块不能为空', 'err'); break; } + + $domain = domain_of($url); + + // ---- 图标 ---- + // icon_auto 勾选 = 允许「全部更新」自动刷新图标(icon_custom=0) + // 取消勾选 / 上传文件 / 给地址 = 自定义固定(icon_custom=1) + $iconAuto = isset($_POST['icon_auto']); + $custom = $iconAuto ? 0 : 1; + $iconFile = trim((string)($_POST['icon'] ?? '')); + + $uploaded = !empty($_FILES['icon_file']['tmp_name']) && is_uploaded_file((string)$_FILES['icon_file']['tmp_name']); + if ($uploaded) { + $bin = (string)@file_get_contents((string)$_FILES['icon_file']['tmp_name']); + if ($bin === '' || strlen($bin) > 2 * 1024 * 1024) { + flash('图标文件过大或为空(限 2MB)', 'err'); + } else { + $new = save_custom_icon($bin, $domain); + if ($new !== '') { + $iconFile = $new; + $custom = 1; + } else { + flash('图标文件无法解析,请换一张(PNG / JPG / SVG,建议正方形)', 'err'); + } + } + } else { + $iconUrl = trim((string)($_POST['icon_url'] ?? '')); + if ($iconUrl !== '') { + $new = download_best_icon([norm_url($iconUrl)], $domain, 1); + if ($new !== '') { + $iconFile = $new; + $custom = 1; + } else { + flash('自定义图标地址下载失败,请检查地址是否可直接访问', 'err'); + } + } + } + // 网址换了但没换图标:清掉旧图标,避免张冠李戴 + if ($iconFile !== '' && $custom === 0 && !is_file(ICON_PATH . '/' . $iconFile)) { + $iconFile = ''; + } + $now = date('Y-m-d H:i:s'); if ($id > 0) { - $st = $pdo->prepare('UPDATE sites SET group_id=?, url=?, name=?, short_name=?, icon=?, is_visible=?, updated_at=? WHERE id=?'); - $st->execute([$gid, $url, $name, $short, $icon, $visible, $now, $id]); - flash('已保存网站'); + $st = $pdo->prepare('UPDATE sites SET group_id=?, url=?, name=?, short_name=?, icon=?, icon_custom=?, is_visible=?, updated_at=? WHERE id=?'); + $st->execute([$gid, $url, $name, $short, $iconFile, $custom, $visible, $now, $id]); + flash($custom ? '已保存(图标已锁定,不再自动更新)' : '已保存网站'); } else { $max = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM sites WHERE group_id=' . $gid)->fetchColumn(); - $st = $pdo->prepare('INSERT INTO sites (group_id,url,name,short_name,icon,sort_order,is_visible,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)'); - $st->execute([$gid, $url, $name, $short, $icon, $max, $visible, $now, $now]); - flash('已添加网站'); + $st = $pdo->prepare('INSERT INTO sites (group_id,url,name,short_name,icon,icon_custom,sort_order,is_visible,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)'); + $st->execute([$gid, $url, $name, $short, $iconFile, $custom, $max, $visible, $now, $now]); + flash($custom ? '已添加网站(图标已锁定,不再自动更新)' : '已添加网站'); } break; } @@ -56,7 +118,9 @@ try { break; } case 'site_toggle': { - $pdo->prepare('UPDATE sites SET is_visible = 1 - is_visible WHERE id=?')->execute([post_id()]); + $id = post_id(); + $pdo->prepare('UPDATE sites SET is_visible = 1 - is_visible WHERE id=?')->execute([$id]); + $ajax = ['state' => state_of('sites', $id, 'is_visible')]; flash('已切换显示状态'); break; } @@ -90,7 +154,9 @@ try { break; } case 'group_toggle': { - $pdo->prepare('UPDATE groups SET is_visible = 1 - is_visible WHERE id=?')->execute([post_id()]); + $id = post_id(); + $pdo->prepare('UPDATE groups SET is_visible = 1 - is_visible WHERE id=?')->execute([$id]); + $ajax = ['state' => state_of('groups', $id, 'is_visible')]; flash('已切换显示状态'); break; } @@ -129,7 +195,9 @@ try { break; } case 'engine_toggle': { - $pdo->prepare('UPDATE engines SET is_active = 1 - is_active WHERE id=?')->execute([post_id()]); + $id = post_id(); + $pdo->prepare('UPDATE engines SET is_active = 1 - is_active WHERE id=?')->execute([$id]); + $ajax = ['state' => state_of('engines', $id, 'is_active')]; flash('已切换启用状态'); break; } @@ -146,6 +214,7 @@ try { 'default_engine' => preg_replace('/[^a-z0-9\-_]/i', '', (string)($_POST['default_engine'] ?? '')), 'primary_color' => preg_match('/^#[0-9a-fA-F]{3,8}$/', (string)($_POST['primary_color'] ?? '')) ? $_POST['primary_color'] : '#2F6BF7', 'open_blank' => isset($_POST['open_blank']) ? '1' : '0', + 'icon_services' => isset($_POST['icon_services']) ? '1' : '0', ]; $st = $pdo->prepare('INSERT INTO settings (k,v) VALUES (?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v'); foreach ($map as $k => $v) { $st->execute([$k, $v]); } @@ -172,9 +241,20 @@ try { flash('未知操作', 'err'); } } catch (Throwable $e) { + if ($isAjax) { + json_out(['ok' => false, 'msg' => $e->getMessage()]); + } flash('操作失败:' . $e->getMessage(), 'err'); } +if ($isAjax) { + unset($_SESSION['flash']); // AJAX 不产生页面提示,避免下次进后台弹出过期提示 + if ($ajax === null) { + json_out(['ok' => false, 'msg' => '该操作不支持 AJAX']); + } + json_out(['ok' => true] + $ajax); +} + header('Location: ' . url($back)); exit; diff --git a/admin/fetch_meta.php b/admin/fetch_meta.php index c7ac78f..e6fbf30 100644 --- a/admin/fetch_meta.php +++ b/admin/fetch_meta.php @@ -1,11 +1,13 @@ true, - 'url' => $url, - 'name' => $meta['name'] !== '' ? $meta['name'] : ($meta['title'] !== '' ? $meta['title'] : $domain), - 'short' => $meta['name'] !== '' ? $meta['name'] : $domain, - 'icon' => $iconFile, - 'icon_url' => $iconFile !== '' ? icon_src($iconFile) : '', + 'ok' => true, + 'url' => $url, + 'final_url' => $meta['final_url'], + 'name' => $name, + 'short' => $name, + 'title' => $meta['title'], + 'icon' => $iconFile, + 'icon_url' => $iconFile !== '' ? icon_src($iconFile) : '', + 'candidates' => count($meta['icons']), ], JSON_UNESCAPED_UNICODE); diff --git a/admin/index.php b/admin/index.php index ebfe52e..16ce218 100644 --- a/admin/index.php +++ b/admin/index.php @@ -5,6 +5,7 @@ declare(strict_types=1); require_once __DIR__ . '/_auth.php'; require_login(); +send_no_cache(); $pdo = db(); $p = isset($_GET['p']) ? (string)$_GET['p'] : 'sites'; @@ -26,12 +27,35 @@ function flash_html(?array $f): string return '
' . e($f['m']) . '
'; } -function toggle_form(string $action, int $id): string +/** 眼睛(显示)/ 斜杠眼(隐藏)图标 —— 用内联 SVG,避免 emoji 字体差异导致显示成方块 */ +function eye_svg(bool $on): string { - return '
' - . csrf_field() . '' + $slash = $on ? '' : ''; + return ''; +} + +/** + * 显隐切换按钮:表单自带 ajax=1,前端原地切换、不跳页; + * 即使浏览器禁用 JS,表单也会正常提交并回到当前列表页(p 已带上)。 + */ +function toggle_form(string $action, int $id, int $state, string $p = ''): string +{ + $on = $state === 1; + return '' + . csrf_field() + . '' . '' - . ''; + . '' + . '' + . ''; } function move_form(string $action, int $id, string $dir, string $label, string $p): string @@ -48,15 +72,17 @@ ob_start(); if ($p === 'sites'): $rows = $pdo->query('SELECT s.*, g.name AS gname FROM sites s JOIN groups g ON g.id=s.group_id ORDER BY g.sort_order, g.id, s.sort_order, s.id')->fetchAll(); + $customCount = 0; + foreach ($rows as $rr) { if ((int)($rr['icon_custom'] ?? 0) === 1) { $customCount++; } } ?>

新增 / 编辑网站

- +
-
+ @@ -74,12 +100,32 @@ if ($p === 'sites'): -
-

网站列表()

- +
+

网站列表()

+ 其中 个使用自定义图标(不会自动更新) +
+
- + - + -
图标简称(展示)名称网址板块显示排序操作
+ + 自定义 + @@ -118,7 +171,7 @@ if ($p === 'sites'):
+
- +
query('SELECT COUNT(*) FROM sites WHERE group_id=' . (int)$g['id'])->fetchColumn(); ?> @@ -151,7 +204,7 @@ elseif ($p === 'groups'): ?> - + -
名称展示方式网站数显示排序操作
@@ -163,7 +216,7 @@ elseif ($p === 'groups'): ?>
+
- +
- - +
+

搜索引擎列表

+ 点最右侧「眼睛」图标即在该行原地切换,不会跳页 +
+
名称搜索地址联想类型启用排序操作
+ - + -
名称搜索地址联想类型前台显示排序操作
■ () 已启用' : '未启用' ?> + + +
-

提示:联想接口需填写对应平台的地址,解析类型可选 bing_osjson / baidu_sugrec / google_firefox / sogou_ajaj / so360 / ddg。

+ +

提示:联想接口需填写对应平台的地址,解析类型可选 bing_osjson / baidu_sugrec / google_firefox / sogou_ajaj / so360 / ddg。「前台显示」为该引擎是否出现在首页顶部搜索框上方的切换条里。

+ +

勾上后,当站点自身没有提供高清图标时,会依次尝试 icon.horse、Yandex 图标服务;关掉则只用站点自己声明的图标。

diff --git a/admin/update_all.php b/admin/update_all.php index 056da5a..421cdb5 100644 --- a/admin/update_all.php +++ b/admin/update_all.php @@ -1,51 +1,57 @@ query('SELECT COUNT(*) FROM sites')->fetchColumn(); -$rows = $pdo->query('SELECT id, url FROM sites ORDER BY id LIMIT ' . $limit . ' OFFSET ' . $offset)->fetchAll(); +$rows = $pdo->query('SELECT id, url, icon_custom FROM sites ORDER BY id LIMIT ' . $limit . ' OFFSET ' . $offset)->fetchAll(); $updated = 0; -$failed = 0; +$skipped = 0; +$failed = 0; $now = date('Y-m-d H:i:s'); foreach ($rows as $row) { $url = norm_url((string)$row['url']); + $custom = (int)$row['icon_custom'] === 1; + $meta = fetch_site_meta($url); $domain = domain_of($meta['final_url'] ?: $url); $name = $meta['name'] !== '' ? $meta['name'] : $meta['title']; - $iconFile = ''; - if (!empty($meta['icon'])) { - $iconFile = download_icon($meta['icon'], $domain); - } - if ($iconFile === '' && $domain !== '') { - $iconFile = download_icon('http://' . $domain . '/favicon.ico', $domain); - } $sets = ['updated_at=?']; $args = [$now]; if ($name !== '') { $sets[] = 'name=?'; $args[] = $name; } - if ($iconFile !== '') { $sets[] = 'icon=?'; $args[] = $iconFile; } + + if ($custom) { + // 用户自定义图标:绝不覆盖 + $skipped++; + } else { + $iconFile = download_best_icon($meta['icons'], $domain); + if ($iconFile !== '') { $sets[] = 'icon=?'; $args[] = $iconFile; } + if ($iconFile !== '' || $name !== '') { $updated++; } else { $failed++; } + } // 注意:这里绝不修改 short_name(用户自定义简称) $args[] = (int)$row['id']; try { $pdo->prepare('UPDATE sites SET ' . implode(',', $sets) . ' WHERE id=?')->execute($args); - if ($iconFile !== '' || $name !== '') { $updated++; } else { $failed++; } } catch (Throwable $e) { $failed++; } @@ -57,5 +63,6 @@ echo json_encode([ 'total' => $total, 'offset' => $next, 'updated' => $updated, + 'skipped' => $skipped, 'failed' => $failed, ], JSON_UNESCAPED_UNICODE); diff --git a/assets/.htaccess b/assets/.htaccess new file mode 100644 index 0000000..6b423de --- /dev/null +++ b/assets/.htaccess @@ -0,0 +1,29 @@ +# --------------------------------------------------------------------------- +# SimpleNav —— assets/ 静态资源缓存策略 +# +# 背景与理由: +# PHP 动态页面统一用 send_no_cache() 声明「不要缓存」;而 CSS / JS / 图标 / +# 字体这类静态资源如果也不缓存,每次访问都要重新下载一遍,白白浪费带宽。 +# 这里采用「长缓存 + 版本号」策略:页面引用资源时一律带 ?v=- +# (见 functions.php 的 asset() / icon_src()),文件内容一变版本号就变, +# 浏览器自然会重新拉取,所以可以放心地给一年缓存。 +# +# 注意:本目录下的文件不会被 Nginx 的 fastcgi_cache 影响(不经过 PHP)。 +# --------------------------------------------------------------------------- + + + Header set Cache-Control "public, max-age=31536000, immutable" + + + +# 禁止在静态目录执行脚本 + + + Require all denied + + + Order allow,deny + Deny from all + + +Options -ExecCGI diff --git a/assets/css/admin.css b/assets/css/admin.css index 9c24339..9d66ecd 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -1,7 +1,12 @@ /* 简航 SimpleNav —— 后台样式 */ +:root { + --wrap: 1140px; + --gutter: 16px; + --pad-x: max(var(--gutter), calc((100% - var(--wrap)) / 2)); +} body.admin { background: #eef0f3; color: #333; font-size: 14px; } .topbar { - display: flex; align-items: center; gap: 16px; padding: 0 20px; height: 56px; + display: flex; align-items: center; gap: 16px; padding: 0 var(--pad-x); height: 56px; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.06); position: sticky; top: 0; z-index: 50; } .topbar .brand { font-size: 17px; font-weight: 700; } @@ -12,7 +17,7 @@ body.admin { background: #eef0f3; color: #333; font-size: 14px; } .topnav a:hover { background: #f2f4f7; } .topnav a.active { background: #2F6BF7; color: #fff; } .topright { display: flex; align-items: center; gap: 10px; color: #666; } -.wrap { max-width: 1140px; margin: 20px auto 60px; padding: 0 16px; } +.wrap { padding: 0 var(--pad-x); margin: 20px 0 60px; } .card { background: #fff; border-radius: 12px; padding: 18px; margin-bottom: 18px; box-shadow: 0 2px 10px rgba(0,0,0,.04); } .card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } @@ -43,6 +48,8 @@ body.admin { background: #eef0f3; color: #333; font-size: 14px; } .alert.err { background: #fdecec; color: #c0392b; } .tbl { width: 100%; border-collapse: collapse; font-size: 13px; } +/* 窄屏下表格自己横向滚动,绝不撑破页面 */ +.tbl-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } .tbl th, .tbl td { padding: 9px 8px; text-align: left; border-bottom: 1px solid #f0f1f3; vertical-align: middle; } .tbl th { color: #888; font-weight: 500; } .tbl tr:hover td { background: #fafbfc; } @@ -52,11 +59,68 @@ body.admin { background: #eef0f3; color: #333; font-size: 14px; } .mini { width: 26px; height: 26px; border-radius: 6px; object-fit: contain; background: #f4f5f7; } .mini.letter { display: inline-flex; align-items: center; justify-content: center; color: #fff; font-size: 13px; } .inline { display: inline; } -.ico-btn { border: none; background: #f2f4f7; width: 28px; height: 28px; border-radius: 6px; cursor: pointer; color: #555; margin: 0 1px; } +.ico-btn { + display: inline-flex; align-items: center; justify-content: center; + border: none; background: #f2f4f7; width: 28px; height: 28px; border-radius: 6px; + cursor: pointer; color: #555; margin: 0 1px; padding: 0; transition: background .15s, color .15s; +} .ico-btn:hover { background: #e6e9ee; } +.ico-btn:disabled { opacity: .5; cursor: default; } .ico-btn.danger { color: #c0392b; } +.ico-btn.danger:hover { background: #fdecec; } .ok-dot { color: #1a7f45; font-size: 12px; } +/* 显示 / 隐藏 切换按钮:绿=显示中,灰=已隐藏 */ +.ico-btn.toggle { color: #9aa3af; } +.ico-btn.toggle:hover { background: #e6e9ee; color: #555; } +.ico-btn.toggle.on { color: #1a7f45; background: #e8f6ee; } +.ico-btn.toggle.on:hover { background: #d6efe1; } +.ico-btn.toggle svg { display: block; } + +/* 状态胶囊 */ +.state { + display: inline-block; min-width: 52px; padding: 2px 8px; border-radius: 10px; + font-size: 12px; line-height: 18px; text-align: center; vertical-align: middle; +} +.state.on { background: #e8f6ee; color: #1a7f45; } +.state.off { background: #f1f3f5; color: #98a2b3; } + +/* 「自定义图标」标记 */ +.badge { + display: inline-block; margin-left: 6px; padding: 1px 6px; border-radius: 8px; + background: #eef3ff; color: #2F6BF7; font-size: 11px; line-height: 16px; vertical-align: middle; +} + +/* 图数字段 */ +.icon-field { + display: flex; gap: 16px; align-items: flex-start; padding: 12px; + border: 1px dashed #d9dde3; border-radius: 10px; background: #fafbfc; +} +.icon-field-preview { flex: 0 0 72px; } +.icon-preview { + display: flex; align-items: center; justify-content: center; width: 72px; height: 72px; + border-radius: 14px; background: #fff; border: 1px solid #e6e9ee; overflow: hidden; +} +.icon-preview img { width: 100%; height: 100%; object-fit: contain; background: #fff; } +.icon-preview .ph { color: #bbb; font-size: 12px; } +.icon-field-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; } +.icon-field-title { font-size: 13px; color: #555; font-weight: 600; } +.icon-field-body input[type=text] { + height: 38px; padding: 0 10px; border: 1px solid #d9dde3; border-radius: 8px; font-size: 14px; width: 100%; +} +.icon-field-body input[type=text]:focus { outline: none; border-color: #2F6BF7; } +.icon-row2 { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; } +.icon-row2 .chk { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; font-size: 13px; color: #555; } +.icon-row2 input[type=file] { + font-size: 13px; color: #666; background: #fff; border: 1px solid #d9dde3; + border-radius: 8px; padding: 6px 8px; max-width: 300px; +} +.icon-field .hint { line-height: 1.75; margin: 0; } +.icon-field .hint b { color: #2F6BF7; } + +/* 已隐藏的行稍作弱化 */ +.tbl tr.row-off td:first-child, .tbl tr.row-off td:nth-child(2) { color: #98a2b3; } + /* 登录页 */ .login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; } .login-card { width: 340px; background: #fff; border-radius: 14px; padding: 30px 26px; box-shadow: 0 10px 30px rgba(0,0,0,.08); text-align: center; } @@ -69,6 +133,10 @@ body.admin { background: #eef0f3; color: #333; font-size: 14px; } @media (max-width: 720px) { .grid-form { grid-template-columns: 1fr; } - .topbar { flex-wrap: wrap; height: auto; padding: 10px 14px; } + .topbar { flex-wrap: wrap; height: auto; padding: 10px var(--pad-x); } + .tbl { min-width: 620px; } .tbl .ell { max-width: 120px; } + .icon-field { flex-direction: column; } + .icon-field-preview { flex: 0 0 auto; } + .icon-row2 input[type=file] { max-width: 100%; } } diff --git a/assets/css/style.css b/assets/css/style.css index 2173971..7c958a8 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -1,116 +1,192 @@ -/* 简航 SimpleNav —— 样式(复刻 n.yangyq.net 的灰底白卡片风格,去 Bootstrap 依赖) */ -:root { --primary: #2F6BF7; } +/* 简航 SimpleNav —— 前台样式 + 在 n.yangyq.net「浅灰底 + 白卡片」基础上做了现代化处理: + 圆角更大、阴影分层、悬停微动效、顶部品牌区与胶囊式搜索引擎切换。 */ +:root { + --primary: #2F6BF7; + --primary-dark: #2456cc; + --primary-soft: #eef3ff; + --bg-1: #f4f6fa; + --bg-2: #e8ecf4; + --card: #ffffff; + --text: #22262b; + --muted: #7b8794; + --line: #eaedf3; + --radius-lg: 18px; + --radius: 12px; + /* 版心:只推一次 —— 顶栏与正文共用同一个左右内边距,两边算出来严格相等 */ + --wrap: 1140px; + --gutter: 16px; + --pad-x: max(var(--gutter), calc((100% - var(--wrap)) / 2)); + --shadow-sm: 0 1px 2px rgba(23, 43, 77, .05), 0 4px 12px rgba(23, 43, 77, .05); + --shadow: 0 2px 4px rgba(23, 43, 77, .04), 0 10px 30px rgba(23, 43, 77, .08); +} * { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } body { margin: 0; + min-height: 100vh; font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; - background: #d8d9da; - color: #333; + color: var(--text); + background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-2) 100%) fixed; overflow-x: hidden; + -webkit-font-smoothing: antialiased; } a { text-decoration: none; color: inherit; } -.container { max-width: 1140px; margin: 50px auto 40px; padding: 0 15px; } +.container { padding: 0 var(--pad-x); margin: 0 0 40px; } + +/* ---------- 顶部品牌区 ---------- */ +.hero { text-align: center; padding: 52px 0 22px; } +.hero .logo { + display: block; width: 60px; height: 60px; margin: 0 auto 14px; border-radius: 18px; + box-shadow: 0 8px 22px rgba(47, 107, 247, .28); +} +.hero h1 { margin: 0; font-size: 25px; font-weight: 600; letter-spacing: .5px; } +.hero p { margin: 7px 0 0; font-size: 13px; color: var(--muted); } /* ---------- 搜索 ---------- */ -.s-search { position: relative; z-index: 2000; margin-bottom: 10px; } -.search-type { margin-bottom: 0; } +.s-search { position: relative; z-index: 2000; max-width: 760px; margin: 0 auto 6px; } +.search-type { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-bottom: 14px; } .s-engine { - display: inline-block; padding: 6px 14px; margin: 0 2px 0 0; - background: rgba(255,255,255,.45); border-radius: 5px 5px 0 0; cursor: pointer; - font-size: 14px; line-height: 20px; transition: background .2s; + display: inline-block; padding: 6px 16px; border-radius: 999px; cursor: pointer; + background: rgba(255, 255, 255, .72); border: 1px solid rgba(255, 255, 255, .9); + font-size: 14px; line-height: 20px; color: #4a5568; + transition: transform .18s, box-shadow .18s, background .18s, color .18s; + user-select: none; } .s-engine span { color: var(--ec, #555); } -.s-engine:hover { background: rgba(255,255,255,.75); } -.s-engine.active { background: #fff; } +.s-engine:hover { background: #fff; transform: translateY(-1px); box-shadow: var(--shadow-sm); } +.s-engine.active { + background: var(--ec, var(--primary)); border-color: var(--ec, var(--primary)); + box-shadow: 0 6px 16px rgba(47, 107, 247, .26); +} +.s-engine.active span { color: #fff; } + #search-form { position: relative; } #search-text { - width: 100%; height: 50px; line-height: 50px; text-indent: 14px; font-size: 16px; - border: none; border-radius: 5px; background: #fff; outline: 0; - box-shadow: 0 0.5rem 0.625rem rgba(212,212,212,.3); transition: box-shadow .3s; + width: 100%; height: 56px; line-height: 56px; text-indent: 22px; padding-right: 64px; + font-size: 16px; border: 2px solid transparent; border-radius: 28px; background: #fff; + color: var(--text); outline: 0; box-shadow: var(--shadow); + transition: border-color .2s, box-shadow .25s; +} +#search-text::placeholder { color: #a9b3c0; } +#search-text:focus { + border-color: var(--primary); + box-shadow: 0 0 0 5px rgba(47, 107, 247, .12), var(--shadow); } -#search-text:focus { box-shadow: 0 0 24px 0 rgba(50,50,50,.08); } #search button.submit { - position: absolute; top: 7px; right: 7px; width: 60px; height: 36px; - background: none; border: 0; border-radius: 3px; cursor: pointer; outline: none; -} -#search button.submit::after { - content: ""; display: block; width: 16px; height: 16px; margin: 0 auto; - border: 2px solid #c9c9c9; border-radius: 50%; position: relative; -} -#search button.submit::before { - content: ""; position: absolute; right: 20px; bottom: 9px; width: 7px; height: 2px; - background: #c9c9c9; transform: rotate(45deg); + position: absolute; top: 8px; right: 8px; width: 40px; height: 40px; + display: flex; align-items: center; justify-content: center; + border: 0; border-radius: 50%; background: var(--primary); color: #fff; + cursor: pointer; outline: none; transition: background .2s, transform .2s; } +#search button.submit:hover { background: var(--primary-dark); transform: scale(1.05); } +#search button.submit:active { transform: scale(.96); } + #word { - position: absolute; list-style: none; top: 55px; left: 0; width: 100%; margin: 0; - background: #fff; border-radius: 5px; z-index: 20000; padding: 10px; - box-shadow: 0 0 10px #bbb; display: none; + position: absolute; list-style: none; top: 64px; left: 0; width: 100%; margin: 0; + background: #fff; border-radius: 16px; z-index: 20000; padding: 8px; + box-shadow: 0 16px 44px rgba(23, 43, 77, .16); display: none; + max-height: 336px; overflow-y: auto; } -#word li { height: 35px; padding: 0 8px; line-height: 35px; cursor: pointer; font-size: 16px; border-radius: 5px; } -#word li:hover { background: #eee; } +#word li { + padding: 0 14px; height: 38px; line-height: 38px; cursor: pointer; + font-size: 15px; border-radius: 10px; color: #3d4753; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +#word li:hover { background: var(--primary-soft); color: var(--primary); } /* ---------- 板块卡片 ---------- */ .mylist { - list-style: none; margin: 16px 0; padding: 12px 20px; - background: #fff; border-radius: 15px; box-shadow: 0 0.5rem 0.625rem rgba(212,212,212,.3); - display: flex; flex-wrap: wrap; + list-style: none; margin: 18px 0; padding: 16px 20px 14px; + background: var(--card); border-radius: var(--radius-lg); box-shadow: var(--shadow); + display: flex; flex-wrap: wrap; transition: box-shadow .25s; } +.mylist:hover { box-shadow: 0 4px 8px rgba(23, 43, 77, .05), 0 14px 38px rgba(23, 43, 77, .1); } .mylist > li.title { - width: 100%; margin: 0 0 10px; padding: 0 0 8px 10px; height: auto; - font-size: 16px; font-weight: 600; line-height: 24px; - border-bottom: 1px solid #eee; position: relative; + width: 100%; margin: 0 0 12px; padding: 0 0 10px 14px; height: auto; + font-size: 16px; font-weight: 600; line-height: 22px; color: #1f2733; + border-bottom: 1px solid var(--line); position: relative; } .mylist > li.title::before { - content: ""; position: absolute; left: 0; top: 3px; width: 4px; height: 18px; - background: var(--primary); border-radius: 2px; + content: ""; position: absolute; left: 0; top: 3px; width: 4px; height: 17px; + background: linear-gradient(180deg, var(--primary), #6f9bff); + border-radius: 2px; } .mylist > li.site { margin: 2px; } .mylist li.site a { - display: flex; flex-direction: column; align-items: center; width: 100px; padding: 10px 4px; - border-radius: 10px; transition: background .2s; + display: flex; flex-direction: column; align-items: center; width: 104px; + padding: 12px 6px 10px; border-radius: 14px; + transition: background .2s, transform .2s; } -.mylist li.site a:hover { background: #f0f1f2; } -.mylist li.site .icon { width: 45px; height: 45px; border-radius: 10px; background: #fff; object-fit: contain; } +.mylist li.site a:hover { background: linear-gradient(180deg, #f7f9ff, var(--primary-soft)); transform: translateY(-3px); } +.mylist li.site .icon { + width: 46px; height: 46px; border-radius: 12px; background: #fff; + object-fit: contain; box-shadow: 0 2px 8px rgba(23, 43, 77, .09); + transition: transform .2s; +} +.mylist li.site a:hover .icon { transform: scale(1.07); } .mylist li.site .ico-letter { - width: 45px; height: 45px; border-radius: 10px; color: #fff; font-size: 20px; font-weight: 600; + width: 46px; height: 46px; border-radius: 12px; color: #fff; font-size: 20px; font-weight: 600; display: flex; align-items: center; justify-content: center; + box-shadow: 0 2px 8px rgba(23, 43, 77, .14); transition: transform .2s; } +.mylist li.site a:hover .ico-letter { transform: scale(1.07); } .mylist li.site .name { - width: 100%; margin-top: 6px; text-align: center; font-size: 13px; color: #555; + width: 100%; margin-top: 8px; text-align: center; font-size: 13px; color: #5b6673; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + transition: color .2s; } +.mylist li.site a:hover .name { color: var(--primary); } /* ---------- 合集标签页 ---------- */ -.collections { margin: 16px 0; } +.collections { margin: 24px 0 0; } .collections-title { - font-size: 16px; font-weight: 600; padding: 0 0 8px 10px; margin: 0 0 10px; - position: relative; + font-size: 16px; font-weight: 600; color: #1f2733; + padding: 0 0 10px 14px; margin: 0 0 14px; position: relative; } .collections-title::before { - content: ""; position: absolute; left: 0; top: 3px; width: 4px; height: 18px; - background: var(--primary); border-radius: 2px; + content: ""; position: absolute; left: 0; top: 3px; width: 4px; height: 17px; + background: linear-gradient(180deg, var(--primary), #6f9bff); border-radius: 2px; } -.tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; } +.tabs { display: flex; flex-wrap: wrap; gap: 9px; margin-bottom: 14px; } .tabs .tab { - border: none; background: rgba(255,255,255,.65); color: #444; cursor: pointer; - padding: 7px 16px; border-radius: 18px; font-size: 14px; transition: all .2s; + border: 1px solid rgba(255, 255, 255, .9); background: rgba(255, 255, 255, .72); + color: #4a5568; cursor: pointer; padding: 8px 18px; border-radius: 999px; + font-size: 14px; transition: all .18s; +} +.tabs .tab:hover { background: #fff; transform: translateY(-1px); box-shadow: var(--shadow-sm); } +.tabs .tab.active { + background: var(--primary); border-color: var(--primary); color: #fff; + box-shadow: 0 6px 16px rgba(47, 107, 247, .26); } -.tabs .tab:hover { background: #fff; } -.tabs .tab.active { background: var(--primary); color: #fff; } .tab-panel { display: none; } -.tab-panel.active { display: flex; } +.tab-panel.active { display: flex; animation: fadeUp .28s ease both; } +@keyframes fadeUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: none; } +} /* ---------- 页脚 ---------- */ -.foot { text-align: center; color: #8a8a8a; font-size: 12px; margin: 24px 0 0; } -.foot a { color: #8a8a8a; } +.foot { + text-align: center; color: #97a1ae; font-size: 12px; margin: 30px 0 0; + padding-top: 18px; border-top: 1px solid rgba(23, 43, 77, .06); +} +.foot a { color: #97a1ae; transition: color .2s; } .foot a:hover { color: var(--primary); } /* ---------- 响应式 ---------- */ @media (max-width: 767px) { - .container { margin-top: 24px; } - .mylist { border-radius: 10px; padding: 10px 12px; } - .mylist li.site a { width: 25%; } - .mylist li.site .icon, .mylist li.site .ico-letter { width: 40px; height: 40px; } + .hero { padding: 28px 0 16px; } + .hero .logo { width: 52px; height: 52px; border-radius: 15px; } + .hero h1 { font-size: 21px; } + .s-search { margin-bottom: 2px; } + .search-type { gap: 6px; margin-bottom: 10px; } + .s-engine { padding: 5px 13px; font-size: 13px; } + #search-text { height: 50px; line-height: 50px; font-size: 15px; text-indent: 18px; } + #word { top: 58px; } + .mylist { border-radius: 14px; padding: 12px 12px 10px; } + .mylist li.site a { width: 25%; padding: 10px 2px 8px; } + .mylist li.site .icon, .mylist li.site .ico-letter { width: 42px; height: 42px; } .mylist li.site .name { font-size: 12px; } } diff --git a/assets/js/admin.js b/assets/js/admin.js index de155b1..58991a4 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -1,7 +1,15 @@ /* 简航 SimpleNav —— 后台交互 */ (function () { 'use strict'; - var $ = function (s) { return document.querySelector(s); }; + var $ = function (s, r) { return (r || document).querySelector(s); }; + var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); }; + + /* ---------- 显示图标预览 ---------- */ + function setPreview(src) { + var box = $('#f-icon-preview'); + if (!box) return; + box.innerHTML = src ? '' : '无'; + } /* ---------- 自动获取网站信息 ---------- */ var fetchBtn = $('#btn-fetch'); @@ -10,7 +18,7 @@ var url = ($('#f-url') || {}).value || ''; var hint = $('#fetch-hint'); if (!url) { if (hint) hint.textContent = '请先填写网址'; return; } - if (hint) hint.textContent = '正在获取…'; + if (hint) hint.textContent = '正在获取标题与高清图标…'; fetchBtn.disabled = true; fetch(fetchBtn.getAttribute('data-url') + '?url=' + encodeURIComponent(url)) .then(function (r) { return r.json(); }) @@ -19,23 +27,73 @@ if (!d.ok) { if (hint) hint.textContent = d.msg || '获取失败'; return; } if ($('#f-name')) $('#f-name').value = d.name || ''; if ($('#f-short') && !$('#f-short').value) $('#f-short').value = d.short || d.name || ''; - if ($('#f-icon')) $('#f-icon').value = d.icon || ''; - if (hint) hint.textContent = (d.icon ? '已获取名称与图标' : '已获取名称(未找到图标)') + (d.url ? ':' + d.url : ''); + + var auto = $('#f-icon-auto'); + var locked = auto && !auto.checked; + if (!locked) { + if ($('#f-icon')) $('#f-icon').value = d.icon || ''; + setPreview(d.icon_url || ''); + } + var parts = ['已获取名称']; + parts.push(d.icon ? ('已获取图标(候选 ' + (d.candidates || 1) + ' 个)') : '未找到图标,将回退为首字母块'); + if (locked) parts.push('(当前为自定义图标,未覆盖)'); + if (hint) hint.textContent = parts.join(','); }) .catch(function () { fetchBtn.disabled = false; if (hint) hint.textContent = '网络错误'; }); }); } + + /* ---------- 图标:上传文件 / 自定义地址 ---------- */ + var fileInput = $('#f-icon-file'); + if (fileInput) { + fileInput.addEventListener('change', function () { + var f = fileInput.files && fileInput.files[0]; + if (!f) return; + var auto = $('#f-icon-auto'); + if (auto) auto.checked = false; // 一旦自定义,自动取消「允许自动更新」 + if (window.URL && URL.createObjectURL) { setPreview(URL.createObjectURL(f)); } + var hint = $('#fetch-hint'); + if (hint) hint.textContent = '已选择本地图标:' + f.name + '(保存后生效,并将锁定不再自动更新)'; + }); + } + var urlInput = $('#f-icon-url'); + if (urlInput) { + urlInput.addEventListener('change', function () { + var v = urlInput.value.trim(); + if (!v) return; + var auto = $('#f-icon-auto'); + if (auto) auto.checked = false; + setPreview(v); + }); + } + var autoBox = $('#f-icon-auto'); + if (autoBox) { + autoBox.addEventListener('change', function () { + var hint = $('#fetch-hint'); + if (!hint) return; + hint.textContent = autoBox.checked + ? '已开启自动更新:该网站图标会在「全部更新」时刷新' + : '已关闭自动更新:该网站图标将被锁定'; + }); + } + + /* ---------- 清空表单 ---------- */ var resetBtn = $('#btn-reset'); if (resetBtn) { resetBtn.addEventListener('click', function () { - ['f-id', 'f-url', 'f-name', 'f-short', 'f-icon'].forEach(function (id) { var el = $('#' + id); if (el) el.value = id === 'f-id' ? '0' : ''; }); + ['f-id', 'f-url', 'f-name', 'f-short', 'f-icon', 'f-icon-url'].forEach(function (id) { + var el = $('#' + id); if (el) el.value = id === 'f-id' ? '0' : ''; + }); + var fi = $('#f-icon-file'); if (fi) fi.value = ''; var v = $('#f-visible'); if (v) v.checked = true; + var a = $('#f-icon-auto'); if (a) a.checked = true; var h = $('#fetch-hint'); if (h) h.textContent = ''; + setPreview(''); }); } /* ---------- 编辑网站:回填表单 ---------- */ - document.querySelectorAll('.edit-site').forEach(function (btn) { + $$('.edit-site').forEach(function (btn) { btn.addEventListener('click', function () { var d = btn.dataset; $('#f-id').value = d.id; @@ -44,13 +102,19 @@ $('#f-name').value = d.name; $('#f-short').value = d.short; $('#f-icon').value = d.icon; + if ($('#f-icon-url')) $('#f-icon-url').value = ''; + if ($('#f-icon-file')) $('#f-icon-file').value = ''; + if ($('#f-icon-auto')) $('#f-icon-auto').checked = d.custom !== '1'; $('#f-visible').checked = d.visible === '1'; + setPreview(d.iconUrl || ''); + var hint = $('#fetch-hint'); + if (hint) hint.textContent = d.custom === '1' ? '该网站图标为自定义,不会被「全部更新」覆盖' : ''; window.scrollTo({ top: 0, behavior: 'smooth' }); }); }); /* ---------- 编辑板块 / 引擎 ---------- */ - document.querySelectorAll('.edit-group').forEach(function (btn) { + $$('.edit-group').forEach(function (btn) { btn.addEventListener('click', function () { var d = btn.dataset; $('#g-id').value = d.id; $('#g-name').value = d.name; @@ -61,7 +125,7 @@ var gReset = $('#g-reset'); if (gReset) gReset.addEventListener('click', function () { $('#g-id').value = '0'; $('#g-name').value = ''; $('#g-display').value = 'grid'; $('#g-visible').checked = true; }); - document.querySelectorAll('.edit-engine').forEach(function (btn) { + $$('.edit-engine').forEach(function (btn) { btn.addEventListener('click', function () { var d = btn.dataset; $('#e-id').value = d.id; $('#e-name').value = d.name; $('#e-slug').value = d.slug; @@ -76,29 +140,91 @@ $('#e-param').value = 'q'; $('#e-active').checked = false; }); - /* ---------- 一键“全部更新”(分批循环) ---------- */ + /* ---------- 列表内「显示 / 隐藏」原地切换(不跳页) ---------- */ + $$('form.js-toggle').forEach(function (form) { + form.addEventListener('submit', function (ev) { + var btn = $('button.toggle', form); + if (btn) btn.disabled = true; + ev.preventDefault(); + fetch(form.getAttribute('action'), { + method: 'POST', + body: new FormData(form), + credentials: 'same-origin', + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (btn) btn.disabled = false; + if (!d || !d.ok) { alert((d && d.msg) || '切换失败'); return; } + applyToggleState(form, d.state); + }) + .catch(function () { + if (btn) btn.disabled = false; + // AJAX 失败时退回普通提交,保证功能可用(表单已带 p,会回到当前页) + var ajax = form.querySelector('input[name="ajax"]'); + if (ajax) { ajax.parentNode.removeChild(ajax); } + form.submit(); + }); + }); + }); + + function applyToggleState(form, state) { + var on = state === 1; + var btn = $('button.toggle', form); + if (btn) { + btn.classList.toggle('on', on); + btn.setAttribute('data-on', on ? '1' : '0'); + btn.setAttribute('title', on ? '当前:显示中(点击隐藏)' : '当前:已隐藏(点击显示)'); + var svg = btn.querySelector('path[stroke]'); + var circle = btn.querySelector('circle'); + if (on) { + if (svg) svg.remove(); + } else if (circle && !svg) { + var p = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + p.setAttribute('d', 'M3 3l18 18'); + p.setAttribute('stroke', 'currentColor'); + p.setAttribute('stroke-width', '2'); + p.setAttribute('stroke-linecap', 'round'); + btn.querySelector('svg').appendChild(p); + } + } + // 同行的状态文字(如搜索引擎的「已显示 / 已隐藏」) + var cell = form.closest('td') || form.parentNode; + var st = cell ? $('.js-state', cell) : null; + if (st) { + st.textContent = on ? (st.dataset.onText || '已显示') : (st.dataset.offText || '已隐藏'); + st.classList.toggle('on', on); + st.classList.toggle('off', !on); + } + var row = form.closest('tr'); + if (row) { + row.classList.toggle('row-off', !on); + } + } + + /* ---------- 一键「全部更新」(分批循环) ---------- */ var upBtn = $('#btn-update-all'); if (upBtn) { upBtn.addEventListener('click', function () { - if (!confirm('将批量刷新所有网站的图标与名称(用户自定义简称不受影响),确定继续?')) return; + if (!confirm('将批量刷新所有网站的名称与自动图标。\n用户自定义简称不受影响;标记为「自定义」的图标会被跳过。\n确定继续?')) return; var base = upBtn.getAttribute('data-url'); - var total = 0, updated = 0, failed = 0, offset = 0; + var total = 0, updated = 0, failed = 0, skipped = 0, offset = 0; upBtn.disabled = true; function step() { - fetch(base + '?offset=' + offset + '&limit=6') + fetch(base + '?offset=' + offset + '&limit=3', { credentials: 'same-origin' }) .then(function (r) { return r.json(); }) .then(function (d) { - total = d.total; updated += d.updated; failed += d.failed; offset = d.offset; + total = d.total; updated += d.updated; failed += d.failed; skipped += (d.skipped || 0); offset = d.offset; upBtn.textContent = '⟳ 更新中 ' + Math.min(offset, total) + '/' + total; if (!d.done) { step(); } else { upBtn.disabled = false; - upBtn.textContent = '⟳ 全部更新(图标+名称)'; - alert('更新完成:成功 ' + updated + ',失败/无变化 ' + failed + ',共 ' + total + ' 个网站。'); + upBtn.textContent = '⟳ 全部更新(名称 + 自动图标)'; + alert('更新完成:\n成功 ' + updated + ' 个\n跳过(自定义图标)' + skipped + ' 个\n失败/无变化 ' + failed + ' 个\n共 ' + total + ' 个网站。'); location.reload(); } }) - .catch(function () { upBtn.disabled = false; upBtn.textContent = '⟳ 全部更新(图标+名称)'; alert('更新中断,请重试。'); }); + .catch(function () { upBtn.disabled = false; upBtn.textContent = '⟳ 全部更新(名称 + 自动图标)'; alert('更新中断,请重试。'); }); } step(); }); diff --git a/assets/js/app.js b/assets/js/app.js index a1f8428..d6378ca 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -15,9 +15,8 @@ var param = el.getAttribute('data-param'); var slug = el.getAttribute('data-slug'); var ph = el.getAttribute('data-placeholder') || '搜索'; - if (form) { form.setAttribute('action', search); } + if (form) { form.setAttribute('action', search); form.setAttribute('data-slug', slug); } if (input) { input.setAttribute('name', param); input.setAttribute('placeholder', ph); } - form.setAttribute('data-slug', slug); hideWord(); } tabs.forEach(function (el) { @@ -34,9 +33,9 @@ if (!word) return; if (!items || !items.length) { hideWord(); return; } word.innerHTML = items.map(function (t) { - var d = document.createElement('div'); + var d = document.createElement('li'); d.textContent = t; - return '
  • ' + d.innerHTML + '
  • '; + return d.outerHTML; }).join(''); word.style.display = 'block'; } @@ -69,7 +68,7 @@ document.addEventListener('click', function (e) { if (word && !word.contains(e.target) && e.target !== input) { hideWord(); } var li = e.target.closest ? e.target.closest('#word li') : null; - if (li) { + if (li && input) { input.value = li.textContent; hideWord(); if (form) { form.submit(); } @@ -80,11 +79,36 @@ var collTabs = document.querySelectorAll('#coll-tabs .tab'); collTabs.forEach(function (tab) { tab.addEventListener('click', function () { - collTabs.forEach(function (t) { t.classList.remove('active'); }); + collTabs.forEach(function (t) { + t.classList.remove('active'); + t.setAttribute('aria-selected', 'false'); + }); tab.classList.add('active'); + tab.setAttribute('aria-selected', 'true'); document.querySelectorAll('.tab-panel').forEach(function (p) { p.classList.remove('active'); }); var panel = document.getElementById(tab.getAttribute('data-target')); if (panel) { panel.classList.add('active'); } }); }); + + /* ---------- 图标加载失败 → 退回首字母块(与 PHP 端配色一致) ---------- */ + var PALETTE = ['#2F6BF7', '#5B8DEF', '#0EA5E9', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316']; + function colorOf(s) { + var h = 0; + for (var i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) | 0; } + return PALETTE[Math.abs(h) % PALETTE.length]; + } + document.querySelectorAll('.mylist img.icon').forEach(function (img) { + img.addEventListener('error', function () { + var a = img.closest('a'); + if (!a) { img.style.visibility = 'hidden'; return; } + var nameEl = a.querySelector('.name'); + var label = (nameEl ? nameEl.textContent : '?').trim() || '?'; + var span = document.createElement('span'); + span.className = 'ico-letter'; + span.style.background = colorOf(label); + span.textContent = label.charAt(0); + img.parentNode.replaceChild(span, img); + }, { once: true }); + }); })(); diff --git a/config.php b/config.php index 9133982..6f5c1f7 100644 --- a/config.php +++ b/config.php @@ -16,6 +16,24 @@ define('DATA_PATH', APP_PATH . '/data'); // 数据库等敏感数据( define('DB_PATH', DATA_PATH . '/nav.db'); define('ICON_PATH', APP_PATH . '/uploads/icons'); // 抓取的网站图标(可公开访问) define('ICON_URL', 'uploads/icons'); // 相对站点根 +define('ICON_STORE', 128); // 图标统一存成 128×128(高清屏下 45~64px 显示仍锐利) + +/** + * 是否「由 PHP 再发一颗」绕开服务器侧页面缓存的标记 cookie。 + * + * 背景(2026-09-28 线上实测):服务器装了宝塔面板的「网站加速」,它是一层基于 + * memcached 的整页缓存,本站在其 n.yangyq.net 配置里的过期时间是 30 秒。实测它 + * **读不到** FastCGI 后端返回的 `Cache-Control` 响应头,所以 `no-store` 之类对它 + * 无效;它唯一认的信号是**请求** cookie `X_CACHE=1`。 + * + * 主要解法在根目录 `.htaccess` 里(用 `RequestHeader` 在请求侧注入该标记), + * 对所有访客一次生效、无需 PHP 配合;下面这个开关是**第二道保险**, + * 用于 Nginx 等不读 .htaccess 的环境。 + * + * true = 动态页顺带把该 cookie 种到访客浏览器(首次访问各发一次) + * false = 只发响应头,完全依赖 .htaccess / 服务器配置 + */ +define('BYPASS_PAGE_CACHE', true); /** * 自动探测应用根 URL,兼容「放在子目录 /simplenav」与「放在根目录」两种部署。 diff --git a/db.php b/db.php index 8518f17..e386f03 100644 --- a/db.php +++ b/db.php @@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS sites ( name TEXT NOT NULL DEFAULT '', -- 自动抓取的网站名称 short_name TEXT NOT NULL DEFAULT '', -- 用户自定义简称(页面展示用) icon TEXT NOT NULL DEFAULT '', -- 本地图标文件名 + icon_custom INTEGER NOT NULL DEFAULT 0, -- 1=用户自定义图标("全部更新"不再覆盖) sort_order INTEGER NOT NULL DEFAULT 0, is_visible INTEGER NOT NULL DEFAULT 1, created_at TEXT DEFAULT '', @@ -71,6 +72,15 @@ CREATE TABLE IF NOT EXISTS users ( ); SQL); + // 老库升级:为已存在的 sites 表补 icon_custom 列 + $cols = []; + foreach ($pdo->query('PRAGMA table_info(sites)') as $c) { + $cols[(string)$c['name']] = true; + } + if (!isset($cols['icon_custom'])) { + $pdo->exec('ALTER TABLE sites ADD COLUMN icon_custom INTEGER NOT NULL DEFAULT 0'); + } + // 是否为空库(用于判断是否写入默认数据) $isEmpty = (int)$pdo->query('SELECT COUNT(*) FROM groups')->fetchColumn() === 0; diff --git a/functions.php b/functions.php index 67305e9..5725628 100644 --- a/functions.php +++ b/functions.php @@ -26,14 +26,14 @@ function norm_url(string $url): string return $url; } -/** 取主域名(用于 favicon 与文件名) */ +/** 取主域名(用于图标文件名与首字母回退) */ function domain_of(string $url): string { $host = parse_url(norm_url($url), PHP_URL_HOST); return $host ? preg_replace('/^www\./i', '', $host) : ''; } -/** curl 抓取文本(带 UA、超时、跟随跳转、限长) */ +/** curl 抓取(带 UA、超时、跟随跳转) */ function http_get(string $url, int $maxBytes = 300000, int $timeout = 8): ?array { if (!function_exists('curl_init')) { @@ -56,87 +56,516 @@ function http_get(string $url, int $maxBytes = 300000, int $timeout = 8): ?array CURLOPT_MAXREDIRS => 5, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 5, - CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; SimpleNav/1.0)', + CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36', CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0, CURLOPT_ENCODING => '', + CURLOPT_HTTPHEADER => ['Accept-Language: zh-CN,zh;q=0.9,en;q=0.8'], ]); $body = curl_exec($ch); $type = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE); $eff = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); + $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $err = curl_errno($ch); curl_close($ch); - if ($err || $body === false) { + if ($err || $body === false || $code >= 400) { return null; } return ['body' => $body, 'url' => $eff ?: $url, 'type' => $type]; } +/* ========================================================================= + * 元信息抓取:标题 / 名称 / 图标候选 + * ========================================================================= */ + /** - * 抓取网站元信息:标题、名称、图标地址。 - * @return array{title:string,name:string,icon:string,final_url:string} + * 抓取网站元信息。 + * icons 为「按清晰度从高到低」排好的候选图标地址列表: + * manifest 大图标(512/192) → apple-touch-icon(180) → link[rel=icon](按 sizes 降序)→ /favicon.ico + * + * @return array{title:string,name:string,icon:string,icons:string[],final_url:string} */ function fetch_site_meta(string $url): array { $url = norm_url($url); - $out = ['title' => '', 'name' => '', 'icon' => '', 'final_url' => $url]; + $out = ['title' => '', 'name' => '', 'icon' => '', 'icons' => [], 'final_url' => $url]; + $resp = http_get($url); if ($resp === null) { + $out['icons'] = array_merge(third_party_icon_urls($url), default_icon_candidates($url)); + $out['icon'] = $out['icons'][0] ?? ''; return $out; } - $html = $resp['body']; - $final = $resp['url']; + + $html = ensure_utf8((string)$resp['body']); + $final = $resp['url'] !== '' ? $resp['url'] : $url; $out['final_url'] = $final; - // 编码:优先按 转换到 UTF-8 - $html = ensure_utf8($html); - - // + // ---- 标题 / 名称 ---- if (preg_match('#<title[^>]*>(.*?)#is', $html, $m)) { - $out['title'] = trim(preg_replace('/\s+/', ' ', html_entity_decode($m[1], ENT_QUOTES, 'UTF-8'))); + $out['title'] = trim(preg_replace('/\s+/u', ' ', html_entity_decode($m[1], ENT_QUOTES, 'UTF-8'))); } - // og:site_name 优先作为"名称" - if (preg_match('#]+property=["\']og:site_name["\'][^>]+content=["\']([^"\']+)#i', $html, $m)) { - $out['name'] = trim(html_entity_decode($m[1], ENT_QUOTES, 'UTF-8')); - } elseif ($out['title'] !== '') { + $out['name'] = meta_content($html, 'og:site_name'); + if ($out['name'] === '' && $out['title'] !== '') { // 用标题的首段(按分隔符切分)作为名称 - $t = preg_split('#[\|\-_—·::]#u', $out['title']); - $out['name'] = trim($t[0]); + $seg = preg_split('#[\|\-_—·::]#u', $out['title']); + $out['name'] = trim($seg[0] ?? ''); } - // 图标: - $icon = ''; - if (preg_match_all('#]+>#i', $html, $links)) { - foreach ($links[0] as $tag) { - if (preg_match('#rel=["\'][^"\']*icon[^"\']*["\']#i', $tag)) { - if (preg_match('#href=["\']([^"\']+)["\']#i', $tag, $hm)) { - $cand = html_entity_decode($hm[1], ENT_QUOTES, 'UTF-8'); - // 优先 apple-touch-icon(通常更大更清晰) - if (stripos($tag, 'apple-touch') !== false) { - $icon = $cand; - break; - } - if ($icon === '') { - $icon = $cand; + // ---- 图标候选(按清晰度从高到低)---- + // 1. manifest 里的大图标(常见 512/192) + // 2. apple-touch-icon(常见 180) + // 3. ,按 sizes 降序(SVG 视为最高清) + // 4. 联网搜索:第三方图标聚合服务(仅当上面都没拿到高清图时才会用到) + // 5. 兜底:站点自身 /favicon.ico + [$links, $manifestUrl] = parse_icon_links($html, $final); + $icons = []; + if ($manifestUrl !== '') { + foreach (manifest_icons($manifestUrl) as $u) { + $icons[] = $u; + } + } + foreach ($links as $u) { + $icons[] = $u; + } + foreach (third_party_icon_urls($final) as $u) { + $icons[] = $u; + } + foreach (default_icon_candidates($final) as $u) { + $icons[] = $u; + } + + $out['icons'] = array_values(array_unique(array_filter($icons, static function ($u) { + return is_string($u) && preg_match('#^https?://#i', $u); + }))); + $out['icon'] = $out['icons'][0] ?? ''; + return $out; +} + +/** + * 联网搜索高清图标的第三方聚合服务。 + * 服务器实测:icon.horse 可达(返回 256×256 PNG)、favicon.yandex.net 可达(120×120 PNG); + * 而 favicon.im / api.iowen.cn / favicon.cccyun.cc / Google s2 / DuckDuckGo 均不可达。 + * 可在后台「系统设置」里用 icon_services 关闭。 + */ +function third_party_icon_urls(string $url): array +{ + if (setting('icon_services', '1') !== '1') { + return []; + } + $host = parse_url(norm_url($url), PHP_URL_HOST); + if (!$host) { + return []; + } + $host = strtolower($host); + return [ + 'https://favicon.yandex.net/favicon/' . $host . '?size=120', // 实测 100% 可用,120×120 + 'https://icon.horse/icon/' . $host, // 可达时最高 256×256,偶有超时 + ]; +} + +/** 取 的值(属性顺序无关) */ +function meta_content(string $html, string $key): string +{ + if (!preg_match_all('#]*>#i', $html, $tags)) { + return ''; + } + $k = preg_quote($key, '#'); + foreach ($tags[0] as $t) { + if (!preg_match('#\b(?:property|name|itemprop)\s*=\s*(?:"' . $k . '"|\'' . $k . '\'|' . $k . '[\s/>])#i', $t)) { + continue; + } + if (preg_match('#\bcontent\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))#i', $t, $cm)) { + $v = $cm[1] !== '' ? $cm[1] : ($cm[2] !== '' ? $cm[2] : ($cm[3] ?? '')); + return trim(html_entity_decode($v, ENT_QUOTES, 'UTF-8')); + } + } + return ''; +} + +/** + * 解析页面里的 图标声明。 + * @return array{0:string[],1:string} [候选 URL(高优先级在前), manifest URL] + */ +function parse_icon_links(string $html, string $base): array +{ + $apple = []; + $rel = []; + $manifest = ''; + + if (preg_match_all('#]*>#i', $html, $tags)) { + foreach ($tags[0] as $tag) { + $relv = attr_value($tag, 'rel'); + if ($relv === '') { + continue; + } + $relv = strtolower($relv); + $href = html_entity_decode(attr_value($tag, 'href'), ENT_QUOTES, 'UTF-8'); + if ($href === '') { + continue; + } + if (strpos($relv, 'apple-touch-icon') !== false) { + $apple[] = $href; + continue; + } + if (strpos($relv, 'manifest') !== false) { + if ($manifest === '') { $manifest = $href; } + continue; + } + if (strpos($relv, 'icon') === false) { + continue; + } + $size = 0; + $raw = attr_value($tag, 'sizes'); + if ($raw !== '') { + foreach (preg_split('/[\s,]+/', trim($raw)) ?: [] as $one) { + if (stripos($one, 'any') !== false) { + $size = max($size, 256); + } elseif (preg_match('/^(\d+)\s*x\s*(\d+)$/i', $one, $gm)) { + $size = max($size, (int)$gm[1], (int)$gm[2]); } } } + // SVG 是矢量,视为最高清 + if (preg_match('#\.svg(\?|$)#i', $href) || stripos(attr_value($tag, 'type'), 'svg') !== false) { + $size = max($size, 512); + } + $rel[] = ['size' => $size, 'url' => $href]; } } - if ($icon !== '') { - $out['icon'] = absolutize($icon, $final); - } else { - $p = parse_url($final); - if (!empty($p['scheme']) && !empty($p['host'])) { - $out['icon'] = $p['scheme'] . '://' . $p['host'] . '/favicon.ico'; - } - } - return $out; + + 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)]; } +/** 读取 web app manifest,返回其中图标(按尺寸降序) */ +function manifest_icons(string $manifestUrl): array +{ + $r = http_get($manifestUrl, 200000, 6); + if ($r === null) { + return []; + } + $json = json_decode(trim((string)$r['body']), true); + if (!is_array($json) || empty($json['icons']) || !is_array($json['icons'])) { + return []; + } + $base = $r['url'] !== '' ? $r['url'] : $manifestUrl; + $list = []; + foreach ($json['icons'] as $ic) { + if (empty($ic['src']) || !is_string($ic['src'])) { + continue; + } + $size = 0; + if (!empty($ic['sizes'])) { + foreach (preg_split('/[\s,]+/', (string)$ic['sizes']) ?: [] as $one) { + if (stripos($one, 'any') !== false) { + $size = max($size, 512); + } elseif (preg_match('/^(\d+)\s*x\s*(\d+)$/i', $one, $gm)) { + $size = max($size, (int)$gm[1], (int)$gm[2]); + } + } + } + $list[] = ['size' => $size, 'url' => absolutize($ic['src'], $base)]; + } + usort($list, static function ($a, $b) { return $b['size'] <=> $a['size']; }); + return array_values(array_unique(array_column($list, 'url'))); +} + +/** 兜底候选:站点自身 favicon.ico */ +function default_icon_candidates(string $url): array +{ + $p = parse_url(norm_url($url)); + if (empty($p['host'])) { + return []; + } + $scheme = !empty($p['scheme']) ? $p['scheme'] : 'https'; + return [$scheme . '://' . $p['host'] . '/favicon.ico']; +} + +/** 从标签串里取属性值(支持双引号 / 单引号 / 裸值) */ +function attr_value(string $tag, string $attr): string +{ + $a = preg_quote($attr, '#'); + if (preg_match('#\b' . $a . '\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))#i', $tag, $m)) { + $v = $m[1] !== '' ? $m[1] : ($m[2] !== '' ? $m[2] : ($m[3] ?? '')); + return trim($v); + } + return ''; +} + +/* ========================================================================= + * 图标下载与规范化 + * ========================================================================= */ + +/** + * 按候选顺序抓取图标,返回质量最佳的一张的本地文件名(失败返回 '')。 + * 规则:min(宽,高) 越大越优;达到 $minGood 即停止;另有总耗时预算,避免批量更新超时。 + * + * @param string[] $urls 候选图标地址(已按优先级排序) + */ +function download_best_icon(array $urls, string $key, int $minGood = 120, float $budget = 10.0): string +{ + if (!is_dir(ICON_PATH)) { + @mkdir(ICON_PATH, 0755, true); + } + $best = null; + $bestScore = -1; + $tmps = []; + $tried = 0; + $deadline = microtime(true) + $budget; + + foreach ($urls as $u) { + if (!is_string($u) || $u === '' || !preg_match('#^https?://#i', $u)) { + continue; + } + if ($tried >= 8 || microtime(true) > $deadline) { // 候选上限 + 耗时预算 + break; + } + $tried++; + $r = grab_icon($u); + if ($r === null) { + continue; + } + // 第三方聚合服务在「查不到图标」时会返回一块浅灰底 + 单个深灰字母的生成图, + // 那还不如本地首字母块好看 —— 识别出来就丢弃,继续回退到站点自己的图标。 + if ($r['ext'] === 'png' && is_third_party_icon($u) && looks_generated_tile($r['tmp'])) { + @unlink($r['tmp']); + continue; + } + $tmps[] = $r['tmp']; + $score = min($r['w'], $r['h']); + if ($score > $bestScore) { + $bestScore = $score; + $best = $r; + } + if ($score >= $minGood) { + break; + } + } + + // 清理落选临时文件 + foreach ($tmps as $t) { + if (!$best || $t !== $best['tmp']) { + @unlink($t); + } + } + if ($best === null) { + return ''; + } + + $md5 = md5($key); + $file = $md5 . '.' . $best['ext']; + $dest = ICON_PATH . '/' . $file; + @unlink(ICON_PATH . '/' . $md5 . '.png'); + @unlink(ICON_PATH . '/' . $md5 . '.svg'); + if (!@copy($best['tmp'], $dest)) { + @unlink($best['tmp']); + return ''; + } + @unlink($best['tmp']); + return is_file($dest) ? $file : ''; +} + +/** 是否来自第三方图标聚合服务(这类服务可能返回「生成的占位图」) */ +function is_third_party_icon(string $url): bool +{ + $host = strtolower((string)parse_url($url, PHP_URL_HOST)); + return $host === 'favicon.yandex.net' || $host === 'icon.horse'; +} + +/** + * 判断图片是否是「浅灰底 + 深灰字母」的自动生成占位图。 + * 采样不透明像素:若几乎无色(色度极小)、整体偏亮、且八成以上像素都是同一片浅色底面, + * 就认定它是占位图。彩色 logo 与深色 logo 都不会命中。 + */ +function looks_generated_tile(string $file): bool +{ + $im = @imagecreatefromstring((string)@file_get_contents($file)); + if ($im === false) { + return false; + } + $w = imagesx($im); + $h = imagesy($im); + $sx = max(1, (int)($w / 28)); + $sy = max(1, (int)($h / 28)); + $maxChroma = 0; + $n = 0; + $light = 0; + $sum = 0.0; + for ($y = 0; $y < $h; $y += $sy) { + for ($x = 0; $x < $w; $x += $sx) { + $rgba = imagecolorat($im, $x, $y); + if ((($rgba >> 24) & 0x7F) > 110) { // 透明像素跳过 + continue; + } + $r = ($rgba >> 16) & 0xFF; + $g = ($rgba >> 8) & 0xFF; + $b = $rgba & 0xFF; + $chroma = max($r, $g, $b) - min($r, $g, $b); + if ($chroma > $maxChroma) { $maxChroma = $chroma; } + $v = ($r + $g + $b) / 3; + $sum += $v; + if ($v >= 195 && $chroma < 16) { $light++; } + $n++; + } + } + imagedestroy($im); + if ($n < 24) { + return false; + } + return $maxChroma < 16 && ($sum / $n) > 190 && ($light / $n) >= 0.80; +} + +/** + * 下载单个图标到临时文件。 + * @return array{tmp:string,ext:string,w:int,h:int}|null w/h 为「原始」尺寸,用于择优 + */ +function grab_icon(string $iconUrl): ?array +{ + $resp = http_get($iconUrl, 2 * 1024 * 1024, 5); + if ($resp === null) { + return null; + } + $bin = (string)$resp['body']; + if (strlen($bin) < 64 || strlen($bin) > 3 * 1024 * 1024) { + return null; + } + + $tmp = @tempnam(sys_get_temp_dir(), 'snic'); + if ($tmp === false) { + return null; + } + + // ---- SVG:直接保存(矢量最清晰),挡掉带脚本的 ---- + $head = ltrim(substr($bin, 0, 1024)); + $isSvg = stripos((string)$resp['type'], 'svg') !== false + || stripos($head, ' 512 * 1024 + || preg_match('# $tmp, 'ext' => 'svg', 'w' => 512, 'h' => 512]; + } + + // ---- 位图:GD 归一化为 128×128 PNG(保持比例、透明留白) ---- + if (!function_exists('imagecreatefromstring')) { + @unlink($tmp); + return null; + } + $im = @imagecreatefromstring($bin); + if ($im === false) { + @unlink($tmp); + return null; + } + $w = imagesx($im); + $h = imagesy($im); + if ($w < 8 || $h < 8) { // 太小/损坏,判为无效 + imagedestroy($im); + @unlink($tmp); + return null; + } + + $store = ICON_STORE; + $canvas = imagecreatetruecolor($store, $store); + imagealphablending($canvas, false); + imagesavealpha($canvas, true); + imagefill($canvas, 0, 0, imagecolorallocatealpha($canvas, 0, 0, 0, 127)); + $scale = min($store / $w, $store / $h, 1.0); // 只缩不放,避免把小图硬拉大而变糊 + $nw = max(1, (int)round($w * $scale)); + $nh = max(1, (int)round($h * $scale)); + imagecopyresampled( + $canvas, $im, + (int)(($store - $nw) / 2), (int)(($store - $nh) / 2), 0, 0, + $nw, $nh, $w, $h + ); + imagealphablending($canvas, true); + $ok = imagepng($canvas, $tmp, 6); + imagedestroy($canvas); + imagedestroy($im); + if ($ok === false) { + @unlink($tmp); + return null; + } + return ['tmp' => $tmp, 'ext' => 'png', 'w' => $w, 'h' => $h]; +} + +/** 保存用户上传/指定的自定义图标(文件内容直接给),返回本地文件名 */ +function save_custom_icon(string $binary, string $key): string +{ + if (!is_dir(ICON_PATH)) { + @mkdir(ICON_PATH, 0755, true); + } + $mime = ''; + if (function_exists('finfo_open')) { + $fi = finfo_open(FILEINFO_MIME_TYPE); + $mime = (string)finfo_buffer($fi, $binary); + finfo_close($fi); + } + if (strpos($mime, 'svg') !== false || stripos(ltrim(substr($binary, 0, 1024)), ' time() + 31536000, + 'path' => '/', + 'httponly' => true, + 'samesite' => 'Lax', + ]); + } +} + +/** 资源版本号(mtime + 文件大小),避免浏览器缓存旧文件 */ +function asset(string $relPath): string +{ + $abs = APP_PATH . '/' . ltrim($relPath, '/'); + $v = is_file($abs) ? (string)filemtime($abs) . '-' . (string)filesize($abs) : '0'; + return url($relPath) . '?v=' . $v; } /** 名称首字配色(无图标时的回退头像底色) */ diff --git a/index.php b/index.php index 7d5318a..81ab1bc 100644 --- a/index.php +++ b/index.php @@ -1,12 +1,13 @@ prepare("SELECT * FROM sites WHERE group_id=? AND is_visible=1 ORDER BY sort_order, id"); @@ -34,6 +37,25 @@ function site_label(array $s): string { return $s['short_name'] !== '' ? $s['short_name'] : ($s['name'] !== '' ? $s['name'] : domain_of($s['url'])); } + +/** 渲染一个网站格(网格板块与合集共用) */ +function site_tile(array $s): void +{ + $label = site_label($s); + $src = icon_src($s['icon']); + ?> +
  • + + + + + + + + +
  • + @@ -41,21 +63,32 @@ function site_label(array $s): string <?= e(site_name()) ?> · <?= e(setting('subtitle', '个人网站导航')) ?> - - - - - - - + + + + + + + + + + +
    + +
    + +

    +

    +
    +