feat: 图标策略升级 + 引擎显隐就地切换 + 前后台一致性;界面美化

按反馈逐条处理:

① 界面美化
- 前台重做:品牌区(logo + 站名 + 副标题)、胶囊式引擎切换、圆角卡片与
  悬停浮起、聚焦光晕、合集标签页淡入动画;版心改为 shell 变量
  (--pad-x = max(gutter, (100% - wrap)/2)),与顶栏左右边缘严格对齐。
- 后台:表格窄屏可横向滚动、状态胶囊、自定义徽章、图标预览框。

② 自动更新图标质量差 + 允许自定义图标
- 抓取改为阶梯择优:manifest 512/192 → apple-touch-icon → <link rel=icon>
  (按 sizes 降序,SVG 视为最高清)→ 第三方聚合服务(favicon.yandex.net
  实测 100% 可用;icon.horse 可达时最高 256px)→ 站点自身 /favicon.ico。
- 逐个候选实测像素,达到 120px 即停;候选上限 8 个 + 10s 总预算,避免超时。
- 用 GD 只缩不放,统一存成 128×128 PNG;识别并丢弃第三方服务的
  「灰底字母」占位块。
- 新增 sites.icon_custom 列(带幂等迁移):可上传图标或填图标地址;
  一旦自定义,「全部更新」只刷新名称、跳过图标。

③ 后台引擎「可见/不可见」不好用
- 小图标换成内联 SVG(原来用 emoji,跨字体显示成方块)。
- 切换改为 AJAX 原地生效 + 状态胶囊同步,不再点一下就跳页;
  所有切换/移动表单都带 p 参数,无 JS 时也能正确回到当前页。
- 修掉一个隐蔽的成因:本站此前完全不发 Cache-Control/Expires/ETag,
  浏览器只能自行猜测缓存策略,导致「改完刷新还是旧的」。现在所有动态页
  统一发 no-store/no-cache。

④ deploy/ 与 data/ 误入版本库
- 两目录整目录忽略,部署脚本移出仓库(存 _tooling/),已 git rm --cached。
  (历史提交里仍含这些文件,如需彻底抹除需 git filter-repo,待确认。)

附带修复(线上实测驱动)
- 服务器侧页面缓存:宝塔「网站加速」Apache(mod_lua) 版是一层基于 memcached
  的 30 秒整页缓存,且**读不到 FastCGI 返回的 Cache-Control**,所以 no-store
  挡不住它;唯一信号是请求 cookie X_CACHE=1。已在根 .htaccess 用
  RequestHeader 在请求侧注入该标记(实测任何访客均 X-Cache: MISS),
  PHP 侧另发一颗 cookie 作为 Nginx 环境的兜底(BYPASS_PAGE_CACHE 开关)。
- 静态资源改为「长缓存 + 版本号」:asset()/icon_src() 拼 mtime+size,
  assets/.htaccess 与根 .htaccess 给出 max-age=31536000 immutable。
- 修 absolutize() 未还原 JSON 转义 \/ 导致部分图标 URL 抓不下来的问题。
- 修部署脚本 md5sum 在 Git Bash 下输出 "hash *path" 导致 38 项假「缺失文件」。
This commit is contained in:
2026-09-28 16:43:10 +08:00
parent b4b81ec45e
commit 853e4dd7c6
18 changed files with 1301 additions and 305 deletions
+32
View File
@@ -6,3 +6,35 @@
</IfModule> </IfModule>
# 关闭目录浏览 # 关闭目录浏览
Options -Indexes Options -Indexes
# 根目录静态资源(favicon / apple-touch-icon / webmanifest)也走「长缓存 + 版本号」
# 注意:assets/ 下的资源由 assets/.htaccess 负责,这里只管根目录这几个文件。
<IfModule mod_headers.c>
<FilesMatch "\.(ico|png|svg|webmanifest)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
# ---------------------------------------------------------------------------
# 绕开「服务器侧整页缓存」
#
# 背景(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」的首次访问。
#
# 若哪天不需要了(例如该站点已关闭「网站加速」),整段删掉即可。
# ---------------------------------------------------------------------------
<IfModule mod_headers.c>
RequestHeader edit Cookie "^(.*)$" "$1; X_CACHE=1"
RequestHeader setifempty Cookie "X_CACHE=1"
</IfModule>
+20 -10
View File
@@ -6,12 +6,14 @@
- **轻量**:纯 PHP + SQLite,不依赖 MySQL / 框架 / Composer;前端无 Bootstrap / jQuery,原生 CSS + JS。 - **轻量**:纯 PHP + SQLite,不依赖 MySQL / 框架 / Composer;前端无 Bootstrap / jQuery,原生 CSS + JS。
- **个人向**:单管理员,页面就是「你自己常用网站的起始页」。 - **个人向**:单管理员,页面就是「你自己常用网站的起始页」。
- **搜索引擎可切换 + 联想下拉**:后台勾选喜欢的搜索引擎即可显示在顶部;输入时下拉联想词由服务端代理(规避跨域)。 - **搜索引擎可切换 + 联想下拉**:后台勾选喜欢的搜索引擎即可显示在顶部;输入时下拉联想词由服务端代理(规避跨域)。后台的「显示/隐藏」按钮点一下**原地生效、不跳页**,且无 JS 时也能正常提交。
- **添加即补全**:填网址自动抓取网站名称、图标(图标下载并规范化后存到服务器本地)。 - **添加即补全**:填网址后先访问该网址拿到标题,再按「高清优先」的顺序去网上找相关图标(`manifest` 512 → `apple-touch-icon` → `<link rel=icon>` → 第三方聚合服务),下载后统一规范化为 **128×128 PNG** 存到服务器本地。
- **图标可自定义,且自定义后不再被覆盖**:可为任一网站上传图标或直接填图标地址;一旦自定义,该网站的图标会被标记保护,「一键全部更新」只刷新它的名称、跳过图标。
- **显示简称**:每个网站有「名称(自动)」和「简称(手动)」两个字段,页面展示你自定义的简称。 - **显示简称**:每个网站有「名称(自动)」和「简称(手动)」两个字段,页面展示你自定义的简称。
- **自定义排序**:网站 / 板块 / 搜索引擎都可上移下移排序。 - **自定义排序**:网站 / 板块 / 搜索引擎都可上移下移排序。
- **一键全部更新**:批量刷新所有网站的图标与名称,**不会改动你自定义的简称**。 - **一键全部更新**:批量刷新所有网站的图标与名称,**不会改动你自定义的简称,也不会覆盖你自定义的图标**。
- **合集标签页**:把「合集」做成标签页,点标签平铺该合集下的全部链接。 - **合集标签页**:把「合集」做成标签页,点标签平铺该合集下的全部链接。
- **前后台永远一致**:所有动态页面都发 `Cache-Control: no-store, no-cache`,静态资源走「长缓存 + 版本号」(文件名不变、URL 带 `?v=mtime-size`)。
## 页面结构(复刻 n.yangyq.net 风格) ## 页面结构(复刻 n.yangyq.net 风格)
@@ -26,15 +28,17 @@ SimpleNav/
├─ index.php 前台首页 ├─ index.php 前台首页
├─ suggest.php 搜索联想代理(多引擎,服务端请求) ├─ suggest.php 搜索联想代理(多引擎,服务端请求)
├─ login.php / logout.php 管理员登录 / 首次初始化 ├─ login.php / logout.php 管理员登录 / 首次初始化
├─ db.php 数据库初始化(建表 + 默认数据) ├─ db.php 数据库初始化(建表 + 默认数据 + 幂等列迁移)
├─ config.php 基础配置 ├─ config.php 基础配置
├─ functions.php 公共函数(抓取元信息、下载图标等) ├─ functions.php 公共函数(抓取元信息、下载图标、资源版本号、不缓存响应头)
├─ .htaccess 根目录安全规则(禁止访问 data/ 与 .git/)
├─ admin/ 后台(网站 / 板块 / 搜索引擎 / 设置) ├─ admin/ 后台(网站 / 板块 / 搜索引擎 / 设置)
│ ├─ index.php 后台页面 │ ├─ index.php 后台页面
│ ├─ actions.php 增删改 / 排序处理 │ ├─ actions.php 增删改 / 排序处理(支持 AJAX 原地切换)
│ ├─ fetch_meta.php 添加网站时自动抓取名称与图标 │ ├─ fetch_meta.php 添加网站时自动抓取名称与图标
│ └─ update_all.php 一键“全部更新”(分批) │ └─ update_all.php 一键“全部更新”(分批)
├─ assets/ CSS / JS / 图标 ├─ assets/ CSS / JS / 图标 / logo
│ └─ .htaccess 静态资源长缓存(配合 ?v= 版本号)
├─ uploads/icons/ 抓取到的网站图标(需可写) ├─ uploads/icons/ 抓取到的网站图标(需可写)
└─ data/ SQLite 数据库(需可写,禁止 Web 访问) └─ data/ SQLite 数据库(需可写,禁止 Web 访问)
``` ```
@@ -43,10 +47,16 @@ SimpleNav/
1. 上传全部文件到站点目录(如 `/www/wwwroot/n.yangyq.net/simplenav`)。 1. 上传全部文件到站点目录(如 `/www/wwwroot/n.yangyq.net/simplenav`)。
2. 确保 web 用户对 `data/` 与 `uploads/icons/` 有写权限。 2. 确保 web 用户对 `data/` 与 `uploads/icons/` 有写权限。
3. 浏览器打开站点 → 首次访问进入「初始化管理员」,创建账号后即可在后台管理。 3. 服务器需开启 PHP 的 **GD 扩展**(图标规范化要用)与 `curl`。
4. 建议在 nginx server{} 中加入 `deploy/nginx-simplenav-deny-data.conf` 里的规则(禁止访问 `data/`)。 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 ...` 会被这类插件视为「不缓存」而直接跳过写入。
## 搜索引擎联想接口说明 ## 搜索引擎联想接口说明
+5 -3
View File
@@ -70,8 +70,10 @@ function admin_layout(string $active, string $title, string $body): void
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($title) ?> · <?= e(site_name()) ?> 后台</title> <title><?= e($title) ?> · <?= e(site_name()) ?> 后台</title>
<link rel="stylesheet" href="<?= e(url('assets/css/style.css')) ?>"> <link rel="icon" href="<?= e(asset('favicon.ico')) ?>" sizes="any">
<link rel="stylesheet" href="<?= e(url('assets/css/admin.css')) ?>"> <link rel="stylesheet" href="<?= e(asset('assets/css/style.css')) ?>">
<link rel="stylesheet" href="<?= e(asset('assets/css/admin.css')) ?>"><?php if (setting('primary_color', '#2F6BF7') !== '#2F6BF7'): ?>
<style>:root{--primary:<?= e(setting('primary_color', '#2F6BF7')) ?>}</style><?php endif; ?>
</head> </head>
<body class="admin"> <body class="admin">
<header class="topbar"> <header class="topbar">
@@ -87,7 +89,7 @@ function admin_layout(string $active, string $title, string $body): void
</div> </div>
</header> </header>
<main class="wrap"><?= $body ?></main> <main class="wrap"><?= $body ?></main>
<script src="<?= e(url('assets/js/admin.js')) ?>"></script> <script src="<?= e(asset('assets/js/admin.js')) ?>"></script>
</body> </body>
</html><?php </html><?php
} }
+90 -10
View File
@@ -1,10 +1,14 @@
<?php <?php
/** /**
* 简航 SimpleNav —— 后台操作处理(全部 POST + CSRF) * 简航 SimpleNav —— 后台操作处理(全部 POST + CSRF)
* 支持 AJAX:请求带 ajax=1 时返回 JSON(用于列表内原地切换显隐/排序)。
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/_auth.php'; require_once __DIR__ . '/_auth.php';
require_login(); require_login();
send_no_cache();
$isAjax = !empty($_POST['ajax']);
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ' . url('admin/')); header('Location: ' . url('admin/'));
@@ -21,6 +25,24 @@ if (!empty($_POST['p'])) {
function flash(string $m, string $t = 'ok'): void { $_SESSION['flash'] = ['m' => $m, 't' => $t]; } function flash(string $m, string $t = 'ok'): void { $_SESSION['flash'] = ['m' => $m, 't' => $t]; }
function post_id(): int { return (int)($_POST['id'] ?? 0); } 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 { try {
switch ($action) { switch ($action) {
@@ -31,22 +53,62 @@ try {
$url = norm_url((string)($_POST['url'] ?? '')); $url = norm_url((string)($_POST['url'] ?? ''));
$name = trim((string)($_POST['name'] ?? '')); $name = trim((string)($_POST['name'] ?? ''));
$short = trim((string)($_POST['short_name'] ?? '')); $short = trim((string)($_POST['short_name'] ?? ''));
$icon = trim((string)($_POST['icon'] ?? ''));
$visible = isset($_POST['is_visible']) ? 1 : 0; $visible = isset($_POST['is_visible']) ? 1 : 0;
if ($url === '' || $gid <= 0) { if ($url === '' || $gid <= 0) {
flash('网址和板块不能为空', 'err'); flash('网址和板块不能为空', 'err');
break; 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'); $now = date('Y-m-d H:i:s');
if ($id > 0) { if ($id > 0) {
$st = $pdo->prepare('UPDATE sites SET group_id=?, url=?, name=?, short_name=?, icon=?, is_visible=?, updated_at=? WHERE id=?'); $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, $icon, $visible, $now, $id]); $st->execute([$gid, $url, $name, $short, $iconFile, $custom, $visible, $now, $id]);
flash('已保存网站'); flash($custom ? '已保存(图标已锁定,不再自动更新)' : '已保存网站');
} else { } else {
$max = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM sites WHERE group_id=' . $gid)->fetchColumn(); $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 = $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, $icon, $max, $visible, $now, $now]); $st->execute([$gid, $url, $name, $short, $iconFile, $custom, $max, $visible, $now, $now]);
flash('已添加网站'); flash($custom ? '已添加网站(图标已锁定,不再自动更新)' : '已添加网站');
} }
break; break;
} }
@@ -56,7 +118,9 @@ try {
break; break;
} }
case 'site_toggle': { 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('已切换显示状态'); flash('已切换显示状态');
break; break;
} }
@@ -90,7 +154,9 @@ try {
break; break;
} }
case 'group_toggle': { 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('已切换显示状态'); flash('已切换显示状态');
break; break;
} }
@@ -129,7 +195,9 @@ try {
break; break;
} }
case 'engine_toggle': { 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('已切换启用状态'); flash('已切换启用状态');
break; break;
} }
@@ -146,6 +214,7 @@ try {
'default_engine' => preg_replace('/[^a-z0-9\-_]/i', '', (string)($_POST['default_engine'] ?? '')), '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', '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', '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'); $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]); } foreach ($map as $k => $v) { $st->execute([$k, $v]); }
@@ -172,9 +241,20 @@ try {
flash('未知操作', 'err'); flash('未知操作', 'err');
} }
} catch (Throwable $e) { } catch (Throwable $e) {
if ($isAjax) {
json_out(['ok' => false, 'msg' => $e->getMessage()]);
}
flash('操作失败:' . $e->getMessage(), 'err'); 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)); header('Location: ' . url($back));
exit; exit;
+16 -16
View File
@@ -1,11 +1,13 @@
<?php <?php
/** /**
* 简航 SimpleNav —— 添加/编辑网站时自动抓取元信息(供后台 AJAX 调用) * 简航 SimpleNav —— 添加/编辑网站时自动抓取元信息(供后台 AJAX 调用)
* 返回:{ ok, name, short, icon, url } * 流程:访问网址拿标题 → 解析页面/ manifest 里的高分辨率图标候选 → 择优下载到本地
* 返回:{ ok, name, short, icon, icon_url, candidates, url }
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/_auth.php'; require_once __DIR__ . '/_auth.php';
require_login(); require_login();
send_no_cache();
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
@@ -15,22 +17,20 @@ if ($url === '') {
exit; exit;
} }
$meta = fetch_site_meta($url); $meta = fetch_site_meta($url);
$domain = domain_of($meta['final_url'] ?: $url); $domain = domain_of($meta['final_url'] ?: $url);
$iconFile = ''; $iconFile = download_best_icon($meta['icons'], $domain);
if (!empty($meta['icon'])) {
$iconFile = download_icon($meta['icon'], $domain); $name = $meta['name'] !== '' ? $meta['name'] : ($meta['title'] !== '' ? $meta['title'] : $domain);
}
if ($iconFile === '' && $domain !== '') {
// 兜底再试一次根目录 favicon.ico
$iconFile = download_icon('http://' . $domain . '/favicon.ico', $domain);
}
echo json_encode([ echo json_encode([
'ok' => true, 'ok' => true,
'url' => $url, 'url' => $url,
'name' => $meta['name'] !== '' ? $meta['name'] : ($meta['title'] !== '' ? $meta['title'] : $domain), 'final_url' => $meta['final_url'],
'short' => $meta['name'] !== '' ? $meta['name'] : $domain, 'name' => $name,
'icon' => $iconFile, 'short' => $name,
'icon_url' => $iconFile !== '' ? icon_src($iconFile) : '', 'title' => $meta['title'],
'icon' => $iconFile,
'icon_url' => $iconFile !== '' ? icon_src($iconFile) : '',
'candidates' => count($meta['icons']),
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
+83 -21
View File
@@ -5,6 +5,7 @@
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/_auth.php'; require_once __DIR__ . '/_auth.php';
require_login(); require_login();
send_no_cache();
$pdo = db(); $pdo = db();
$p = isset($_GET['p']) ? (string)$_GET['p'] : 'sites'; $p = isset($_GET['p']) ? (string)$_GET['p'] : 'sites';
@@ -26,12 +27,35 @@ function flash_html(?array $f): string
return '<div class="' . $cls . '">' . e($f['m']) . '</div>'; return '<div class="' . $cls . '">' . e($f['m']) . '</div>';
} }
function toggle_form(string $action, int $id): string /** 眼睛(显示)/ 斜杠眼(隐藏)图标 —— 用内联 SVG,避免 emoji 字体差异导致显示成方块 */
function eye_svg(bool $on): string
{ {
return '<form method="post" action="' . e(url('admin/actions.php')) . '" class="inline">' $slash = $on ? '' : '<path d="M3 3l18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>';
. csrf_field() . '<input type="hidden" name="action" value="' . e($action) . '">' return '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="currentColor">'
. '<path d="M12 5c-5.5 0-9.6 4.3-11 7 1.4 2.7 5.5 7 11 7s9.6-4.3 11-7c-1.4-2.7-5.5-7-11-7zm0 12a5 5 0 110-10 5 5 0 010 10z"/>'
. '<circle cx="12" cy="12" r="2.6"/>'
. $slash
. '</svg>';
}
/**
* 显隐切换按钮:表单自带 ajax=1,前端原地切换、不跳页;
* 即使浏览器禁用 JS,表单也会正常提交并回到当前列表页(p 已带上)。
*/
function toggle_form(string $action, int $id, int $state, string $p = ''): string
{
$on = $state === 1;
return '<form method="post" action="' . e(url('admin/actions.php')) . '" class="inline js-toggle" data-id="' . $id . '">'
. csrf_field()
. '<input type="hidden" name="action" value="' . e($action) . '">'
. '<input type="hidden" name="id" value="' . $id . '">' . '<input type="hidden" name="id" value="' . $id . '">'
. '<button class="ico-btn" title="切换显示">' . "\u{1F441}" . '</button></form>'; . '<input type="hidden" name="p" value="' . e($p) . '">'
. '<input type="hidden" name="ajax" value="1">'
. '<button type="submit" class="ico-btn toggle' . ($on ? ' on' : '') . '"'
. ' data-on="' . ($on ? '1' : '0') . '"'
. ' title="' . ($on ? '当前:显示中(点击隐藏)' : '当前:已隐藏(点击显示)') . '">'
. eye_svg($on)
. '</button></form>';
} }
function move_form(string $action, int $id, string $dir, string $label, string $p): string function move_form(string $action, int $id, string $dir, string $label, string $p): string
@@ -48,15 +72,17 @@ ob_start();
if ($p === 'sites'): 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(); $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++; } }
?> ?>
<?= flash_html($flash) ?> <?= flash_html($flash) ?>
<section class="card"> <section class="card">
<div class="card-head"> <div class="card-head">
<h2>新增 / 编辑网站</h2> <h2>新增 / 编辑网站</h2>
<button type="button" class="btn ghost" id="btn-update-all" data-url="<?= e(url('admin/update_all.php')) ?>">⟳ 全部更新(图标+名称)</button> <button type="button" class="btn ghost" id="btn-update-all" data-url="<?= e(url('admin/update_all.php')) ?>">⟳ 全部更新(名称 + 自动图标)</button>
</div> </div>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="grid-form" id="site-form"> <form method="post" action="<?= e(url('admin/actions.php')) ?>" class="grid-form" id="site-form" enctype="multipart/form-data">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="action" value="site_save"> <input type="hidden" name="action" value="site_save">
<input type="hidden" name="id" id="f-id" value="0"> <input type="hidden" name="id" id="f-id" value="0">
@@ -74,12 +100,32 @@ if ($p === 'sites'):
<button type="button" class="btn ghost" id="btn-fetch" data-url="<?= e(url('admin/fetch_meta.php')) ?>">自动获取</button> <button type="button" class="btn ghost" id="btn-fetch" data-url="<?= e(url('admin/fetch_meta.php')) ?>">自动获取</button>
</span> </span>
</label> </label>
<label>网站名称(自动) <label>网站名称(自动抓取)
<input type="text" name="name" id="f-name" placeholder="自动抓取"> <input type="text" name="name" id="f-name" placeholder="自动抓取">
</label> </label>
<label>简称(页面显示,手动) <label>简称(页面显示,手动)
<input type="text" name="short_name" id="f-short" placeholder="如:知乎"> <input type="text" name="short_name" id="f-short" placeholder="如:知乎">
</label> </label>
<div class="span2 icon-field">
<div class="icon-field-preview">
<span class="icon-preview" id="f-icon-preview"><span class="ph">无</span></span>
</div>
<div class="icon-field-body">
<div class="icon-field-title">图标</div>
<input type="text" name="icon_url" id="f-icon-url" placeholder="自定义图标图片地址(http/https),留空则用自动获取的">
<div class="row icon-row2">
<input type="file" name="icon_file" id="f-icon-file" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon,.svg,.ico">
<label class="chk"><input type="checkbox" name="icon_auto" id="f-icon-auto" checked> 允许自动更新图标</label>
</div>
<p class="hint">
优先级:<b>上传文件</b> > <b>自定义地址</b> > 「自动获取」抓到的图。<br>
选择上传或填写地址后会自动取消勾选「允许自动更新图标」,该网站图标此后不再被「全部更新」覆盖;
勾回去即可恢复自动。
</p>
</div>
</div>
<label class="chk"><input type="checkbox" name="is_visible" id="f-visible" checked> 显示</label> <label class="chk"><input type="checkbox" name="is_visible" id="f-visible" checked> 显示</label>
<div class="span2 form-actions"> <div class="span2 form-actions">
<button type="submit" class="btn primary">保存</button> <button type="submit" class="btn primary">保存</button>
@@ -90,24 +136,31 @@ if ($p === 'sites'):
</section> </section>
<section class="card"> <section class="card">
<div class="card-head"><h2>网站列表(<?= count($rows) ?>)</h2></div> <div class="card-head">
<table class="tbl"> <h2>网站列表(<?= count($rows) ?>)</h2>
<span class="hint">其中 <?= $customCount ?> 个使用自定义图标(不会自动更新)</span>
</div>
<div class="tbl-wrap"><table class="tbl">
<thead><tr><th>图标</th><th>简称(展示)</th><th>名称</th><th>网址</th><th>板块</th><th>显示</th><th>排序</th><th>操作</th></tr></thead> <thead><tr><th>图标</th><th>简称(展示)</th><th>名称</th><th>网址</th><th>板块</th><th>显示</th><th>排序</th><th>操作</th></tr></thead>
<tbody> <tbody>
<?php foreach ($rows as $r): $label = $r['short_name'] !== '' ? $r['short_name'] : ($r['name'] !== '' ? $r['name'] : domain_of($r['url'])); $src = icon_src($r['icon']); ?> <?php foreach ($rows as $r): $label = $r['short_name'] !== '' ? $r['short_name'] : ($r['name'] !== '' ? $r['name'] : domain_of($r['url'])); $src = icon_src($r['icon']); ?>
<tr> <tr>
<td><?php if ($src): ?><img class="mini" src="<?= e($src) ?>" alt=""><?php else: ?><span class="mini letter" style="background:<?= e(letter_color($label)) ?>"><?= e(mb_substr($label, 0, 1)) ?></span><?php endif; ?></td> <td>
<?php if ($src): ?><img class="mini" src="<?= e($src) ?>" alt=""><?php else: ?><span class="mini letter" style="background:<?= e(letter_color($label)) ?>"><?= e(mb_substr($label, 0, 1)) ?></span><?php endif; ?>
<?php if ((int)($r['icon_custom'] ?? 0) === 1): ?><span class="badge" title="用户自定义图标,不会被自动更新覆盖">自定义</span><?php endif; ?>
</td>
<td><?= e($label) ?></td> <td><?= e($label) ?></td>
<td class="muted"><?= e($r['name']) ?></td> <td class="muted"><?= e($r['name']) ?></td>
<td class="muted ell"><a href="<?= e($r['url']) ?>" target="_blank" rel="noopener"><?= e($r['url']) ?></a></td> <td class="muted ell"><a href="<?= e($r['url']) ?>" target="_blank" rel="noopener"><?= e($r['url']) ?></a></td>
<td><?= e($r['gname']) ?></td> <td><?= e($r['gname']) ?></td>
<td><?= toggle_form('site_toggle', (int)$r['id']) ?></td> <td><?= toggle_form('site_toggle', (int)$r['id'], (int)$r['is_visible']) ?></td>
<td class="nowrap"><?= move_form('site_move', (int)$r['id'], 'up', '上移', '') . move_form('site_move', (int)$r['id'], 'down', '下移', '') ?></td> <td class="nowrap"><?= move_form('site_move', (int)$r['id'], 'up', '上移', '') . move_form('site_move', (int)$r['id'], 'down', '下移', '') ?></td>
<td class="nowrap"> <td class="nowrap">
<button type="button" class="ico-btn edit-site" <button type="button" class="ico-btn edit-site"
data-id="<?= (int)$r['id'] ?>" data-group="<?= (int)$r['group_id'] ?>" data-id="<?= (int)$r['id'] ?>" data-group="<?= (int)$r['group_id'] ?>"
data-url="<?= e($r['url']) ?>" data-name="<?= e($r['name']) ?>" data-url="<?= e($r['url']) ?>" data-name="<?= e($r['name']) ?>"
data-short="<?= e($r['short_name']) ?>" data-icon="<?= e($r['icon']) ?>" data-short="<?= e($r['short_name']) ?>" data-icon="<?= e($r['icon']) ?>"
data-icon-url="<?= e($src) ?>" data-custom="<?= (int)($r['icon_custom'] ?? 0) ?>"
data-visible="<?= (int)$r['is_visible'] ?>" title="编辑">✎</button> data-visible="<?= (int)$r['is_visible'] ?>" title="编辑">✎</button>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="inline" onsubmit="return confirm('确定删除该网站?');"> <form method="post" action="<?= e(url('admin/actions.php')) ?>" class="inline" onsubmit="return confirm('确定删除该网站?');">
<?= csrf_field() ?><input type="hidden" name="action" value="site_delete"> <?= csrf_field() ?><input type="hidden" name="action" value="site_delete">
@@ -118,7 +171,7 @@ if ($p === 'sites'):
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table></div>
</section> </section>
<?php <?php
@@ -143,7 +196,7 @@ elseif ($p === 'groups'): ?>
</form> </form>
</section> </section>
<section class="card"> <section class="card">
<table class="tbl"> <div class="tbl-wrap"><table class="tbl">
<thead><tr><th>名称</th><th>展示方式</th><th>网站数</th><th>显示</th><th>排序</th><th>操作</th></tr></thead> <thead><tr><th>名称</th><th>展示方式</th><th>网站数</th><th>显示</th><th>排序</th><th>操作</th></tr></thead>
<tbody> <tbody>
<?php foreach ($groups as $g): $cnt = (int)$pdo->query('SELECT COUNT(*) FROM sites WHERE group_id=' . (int)$g['id'])->fetchColumn(); ?> <?php foreach ($groups as $g): $cnt = (int)$pdo->query('SELECT COUNT(*) FROM sites WHERE group_id=' . (int)$g['id'])->fetchColumn(); ?>
@@ -151,7 +204,7 @@ elseif ($p === 'groups'): ?>
<td><?= e($g['name']) ?></td> <td><?= e($g['name']) ?></td>
<td><?= $g['display'] === 'tabs' ? '合集标签页' : '普通网格' ?></td> <td><?= $g['display'] === 'tabs' ? '合集标签页' : '普通网格' ?></td>
<td><?= $cnt ?></td> <td><?= $cnt ?></td>
<td><?= toggle_form('group_toggle', (int)$g['id']) ?></td> <td><?= toggle_form('group_toggle', (int)$g['id'], (int)$g['is_visible'], 'groups') ?></td>
<td class="nowrap"><?= move_form('group_move', (int)$g['id'], 'up', '上移', 'groups') . move_form('group_move', (int)$g['id'], 'down', '下移', 'groups') ?></td> <td class="nowrap"><?= move_form('group_move', (int)$g['id'], 'up', '上移', 'groups') . move_form('group_move', (int)$g['id'], 'down', '下移', 'groups') ?></td>
<td class="nowrap"> <td class="nowrap">
<button type="button" class="ico-btn edit-group" data-id="<?= (int)$g['id'] ?>" data-name="<?= e($g['name']) ?>" data-display="<?= e($g['display']) ?>" data-visible="<?= (int)$g['is_visible'] ?>">✎</button> <button type="button" class="ico-btn edit-group" data-id="<?= (int)$g['id'] ?>" data-name="<?= e($g['name']) ?>" data-display="<?= e($g['display']) ?>" data-visible="<?= (int)$g['is_visible'] ?>">✎</button>
@@ -163,7 +216,7 @@ elseif ($p === 'groups'): ?>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table></div>
</section> </section>
<?php <?php
@@ -187,20 +240,27 @@ elseif ($p === 'engines'): ?>
<?php foreach ($SUGGEST_TYPES as $t): ?><option value="<?= e($t) ?>"><?= e($t) ?></option><?php endforeach; ?> <?php foreach ($SUGGEST_TYPES as $t): ?><option value="<?= e($t) ?>"><?= e($t) ?></option><?php endforeach; ?>
</select> </select>
</label> </label>
<label class="chk"><input type="checkbox" name="is_active" id="e-active"> 启用(显示在首页)</label> <label class="chk"><input type="checkbox" name="is_active" id="e-active"> 启用(显示在首页顶部)</label>
<div class="span2 form-actions"><button class="btn primary">保存</button><button type="button" class="btn ghost" id="e-reset">清空</button></div> <div class="span2 form-actions"><button class="btn primary">保存</button><button type="button" class="btn ghost" id="e-reset">清空</button></div>
</form> </form>
</section> </section>
<section class="card"> <section class="card">
<table class="tbl"> <div class="card-head">
<thead><tr><th>名称</th><th>搜索地址</th><th>联想类型</th><th>启用</th><th>排序</th><th>操作</th></tr></thead> <h2>搜索引擎列表</h2>
<span class="hint">点最右侧「眼睛」图标即在该行原地切换,不会跳页</span>
</div>
<div class="tbl-wrap"><table class="tbl">
<thead><tr><th>名称</th><th>搜索地址</th><th>联想类型</th><th>前台显示</th><th>排序</th><th>操作</th></tr></thead>
<tbody> <tbody>
<?php foreach ($engines as $en): ?> <?php foreach ($engines as $en): ?>
<tr> <tr>
<td><span style="color:<?= e($en['color']) ?>">■</span> <?= e($en['name']) ?> <span class="muted">(<?= e($en['slug']) ?>)</span></td> <td><span style="color:<?= e($en['color']) ?>">■</span> <?= e($en['name']) ?> <span class="muted">(<?= e($en['slug']) ?>)</span></td>
<td class="muted ell"><?= e($en['search_url']) ?></td> <td class="muted ell"><?= e($en['search_url']) ?></td>
<td class="muted"><?= e($en['suggest_type']) ?></td> <td class="muted"><?= e($en['suggest_type']) ?></td>
<td><?= $en['is_active'] ? '<span class="ok-dot">已启用</span>' : '<span class="muted">未启用</span>' ?> <?= toggle_form('engine_toggle', (int)$en['id']) ?></td> <td class="nowrap">
<span class="state <?= $en['is_active'] ? 'on' : 'off' ?> js-state" data-on-text="已显示" data-off-text="已隐藏"><?= $en['is_active'] ? '已显示' : '已隐藏' ?></span>
<?= toggle_form('engine_toggle', (int)$en['id'], (int)$en['is_active'], 'engines') ?>
</td>
<td class="nowrap"><?= move_form('engine_move', (int)$en['id'], 'up', '上移', 'engines') . move_form('engine_move', (int)$en['id'], 'down', '下移', 'engines') ?></td> <td class="nowrap"><?= move_form('engine_move', (int)$en['id'], 'up', '上移', 'engines') . move_form('engine_move', (int)$en['id'], 'down', '下移', 'engines') ?></td>
<td class="nowrap"> <td class="nowrap">
<button type="button" class="ico-btn edit-engine" <button type="button" class="ico-btn edit-engine"
@@ -215,8 +275,8 @@ elseif ($p === 'engines'): ?>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table></div>
<p class="hint">提示:联想接口需填写对应平台的地址,解析类型可选 bing_osjson / baidu_sugrec / google_firefox / sogou_ajaj / so360 / ddg。</p> <p class="hint">提示:联想接口需填写对应平台的地址,解析类型可选 bing_osjson / baidu_sugrec / google_firefox / sogou_ajaj / so360 / ddg。「前台显示」为该引擎是否出现在首页顶部搜索框上方的切换条里。</p>
</section> </section>
<?php <?php
@@ -238,6 +298,8 @@ else: // settings ?>
</label> </label>
<label>主题色<input type="text" name="primary_color" value="<?= e(setting('primary_color', '#2F6BF7')) ?>"></label> <label>主题色<input type="text" name="primary_color" value="<?= e(setting('primary_color', '#2F6BF7')) ?>"></label>
<label class="chk"><input type="checkbox" name="open_blank" <?= setting('open_blank', '1') === '1' ? 'checked' : '' ?>> 搜索结果新窗口打开</label> <label class="chk"><input type="checkbox" name="open_blank" <?= setting('open_blank', '1') === '1' ? 'checked' : '' ?>> 搜索结果新窗口打开</label>
<label class="chk"><input type="checkbox" name="icon_services" <?= setting('icon_services', '1') === '1' ? 'checked' : '' ?>> 联网搜索高清图标(第三方聚合服务)</label>
<p class="hint span2">勾上后,当站点自身没有提供高清图标时,会依次尝试 icon.horse、Yandex 图标服务;关掉则只用站点自己声明的图标。</p>
<div class="span2 form-actions"><button class="btn primary">保存设置</button></div> <div class="span2 form-actions"><button class="btn primary">保存设置</button></div>
</form> </form>
</section> </section>
+22 -15
View File
@@ -1,51 +1,57 @@
<?php <?php
/** /**
* 简航 SimpleNav —— 一键“全部更新”(分批)。 * 简航 SimpleNav —— 一键“全部更新”(分批)。
* 批量刷新每个网站的图标与名称;**保留用户自定义的简称 short_name 不变**。 * 批量刷新每个网站的名称与图标;
* · **保留** 用户自定义简称 short_name
* · **跳过** 用户自定义图标(icon_custom=1)——只刷新名称
* 前端按 offset 分批调用,避免一次请求超时。 * 前端按 offset 分批调用,避免一次请求超时。
* 返回:{ done, total, offset, updated, failed } * 返回:{ done, total, offset, updated, skipped, failed }
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/_auth.php'; require_once __DIR__ . '/_auth.php';
require_login(); require_login();
send_no_cache();
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
set_time_limit(30); set_time_limit(90);
$offset = max(0, (int)($_GET['offset'] ?? 0)); $offset = max(0, (int)($_GET['offset'] ?? 0));
$limit = min(12, max(1, (int)($_GET['limit'] ?? 6))); $limit = min(12, max(1, (int)($_GET['limit'] ?? 3)));
$pdo = db(); $pdo = db();
$total = (int)$pdo->query('SELECT COUNT(*) FROM sites')->fetchColumn(); $total = (int)$pdo->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; $updated = 0;
$failed = 0; $skipped = 0;
$failed = 0;
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
foreach ($rows as $row) { foreach ($rows as $row) {
$url = norm_url((string)$row['url']); $url = norm_url((string)$row['url']);
$custom = (int)$row['icon_custom'] === 1;
$meta = fetch_site_meta($url); $meta = fetch_site_meta($url);
$domain = domain_of($meta['final_url'] ?: $url); $domain = domain_of($meta['final_url'] ?: $url);
$name = $meta['name'] !== '' ? $meta['name'] : $meta['title']; $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=?']; $sets = ['updated_at=?'];
$args = [$now]; $args = [$now];
if ($name !== '') { $sets[] = 'name=?'; $args[] = $name; } 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(用户自定义简称) // 注意:这里绝不修改 short_name(用户自定义简称)
$args[] = (int)$row['id']; $args[] = (int)$row['id'];
try { try {
$pdo->prepare('UPDATE sites SET ' . implode(',', $sets) . ' WHERE id=?')->execute($args); $pdo->prepare('UPDATE sites SET ' . implode(',', $sets) . ' WHERE id=?')->execute($args);
if ($iconFile !== '' || $name !== '') { $updated++; } else { $failed++; }
} catch (Throwable $e) { } catch (Throwable $e) {
$failed++; $failed++;
} }
@@ -57,5 +63,6 @@ echo json_encode([
'total' => $total, 'total' => $total,
'offset' => $next, 'offset' => $next,
'updated' => $updated, 'updated' => $updated,
'skipped' => $skipped,
'failed' => $failed, 'failed' => $failed,
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
+29
View File
@@ -0,0 +1,29 @@
# ---------------------------------------------------------------------------
# SimpleNav —— assets/ 静态资源缓存策略
#
# 背景与理由:
# PHP 动态页面统一用 send_no_cache() 声明「不要缓存」;而 CSS / JS / 图标 /
# 字体这类静态资源如果也不缓存,每次访问都要重新下载一遍,白白浪费带宽。
# 这里采用「长缓存 + 版本号」策略:页面引用资源时一律带 ?v=<mtime>-<size>
# (见 functions.php 的 asset() / icon_src()),文件内容一变版本号就变,
# 浏览器自然会重新拉取,所以可以放心地给一年缓存。
#
# 注意:本目录下的文件不会被 Nginx 的 fastcgi_cache 影响(不经过 PHP)。
# ---------------------------------------------------------------------------
<IfModule mod_headers.c>
<FilesMatch "\.(css|js|mjs|png|jpg|jpeg|gif|svg|webp|avif|ico|woff|woff2|ttf|otf|eot|webmanifest)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
# 禁止在静态目录执行脚本
<FilesMatch "\.(php|phtml|phar|pl|py|cgi|asp|jsp|sh)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
Options -ExecCGI
+72 -4
View File
@@ -1,7 +1,12 @@
/* 简航 SimpleNav —— 后台样式 */ /* 简航 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; } body.admin { background: #eef0f3; color: #333; font-size: 14px; }
.topbar { .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; 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; } .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:hover { background: #f2f4f7; }
.topnav a.active { background: #2F6BF7; color: #fff; } .topnav a.active { background: #2F6BF7; color: #fff; }
.topright { display: flex; align-items: center; gap: 10px; color: #666; } .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 { 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; } .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; } .alert.err { background: #fdecec; color: #c0392b; }
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; } .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, .tbl td { padding: 9px 8px; text-align: left; border-bottom: 1px solid #f0f1f3; vertical-align: middle; }
.tbl th { color: #888; font-weight: 500; } .tbl th { color: #888; font-weight: 500; }
.tbl tr:hover td { background: #fafbfc; } .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 { 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; } .mini.letter { display: inline-flex; align-items: center; justify-content: center; color: #fff; font-size: 13px; }
.inline { display: inline; } .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:hover { background: #e6e9ee; }
.ico-btn:disabled { opacity: .5; cursor: default; }
.ico-btn.danger { color: #c0392b; } .ico-btn.danger { color: #c0392b; }
.ico-btn.danger:hover { background: #fdecec; }
.ok-dot { color: #1a7f45; font-size: 12px; } .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-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; } .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) { @media (max-width: 720px) {
.grid-form { grid-template-columns: 1fr; } .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; } .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%; }
} }
+138 -62
View File
@@ -1,116 +1,192 @@
/* 简航 SimpleNav —— 样式(复刻 n.yangyq.net 的灰底白卡片风格,去 Bootstrap 依赖) */ /* 简航 SimpleNav —— 前台样式
:root { --primary: #2F6BF7; } 在 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; } * { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { body {
margin: 0; margin: 0;
min-height: 100vh;
font-size: 14px; font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
background: #d8d9da; color: var(--text);
color: #333; background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-2) 100%) fixed;
overflow-x: hidden; overflow-x: hidden;
-webkit-font-smoothing: antialiased;
} }
a { text-decoration: none; color: inherit; } 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; } .s-search { position: relative; z-index: 2000; max-width: 760px; margin: 0 auto 6px; }
.search-type { margin-bottom: 0; } .search-type { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-bottom: 14px; }
.s-engine { .s-engine {
display: inline-block; padding: 6px 14px; margin: 0 2px 0 0; display: inline-block; padding: 6px 16px; border-radius: 999px; cursor: pointer;
background: rgba(255,255,255,.45); border-radius: 5px 5px 0 0; cursor: pointer; background: rgba(255, 255, 255, .72); border: 1px solid rgba(255, 255, 255, .9);
font-size: 14px; line-height: 20px; transition: background .2s; 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 span { color: var(--ec, #555); }
.s-engine:hover { background: rgba(255,255,255,.75); } .s-engine:hover { background: #fff; transform: translateY(-1px); box-shadow: var(--shadow-sm); }
.s-engine.active { background: #fff; } .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-form { position: relative; }
#search-text { #search-text {
width: 100%; height: 50px; line-height: 50px; text-indent: 14px; font-size: 16px; width: 100%; height: 56px; line-height: 56px; text-indent: 22px; padding-right: 64px;
border: none; border-radius: 5px; background: #fff; outline: 0; font-size: 16px; border: 2px solid transparent; border-radius: 28px; background: #fff;
box-shadow: 0 0.5rem 0.625rem rgba(212,212,212,.3); transition: box-shadow .3s; 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 { #search button.submit {
position: absolute; top: 7px; right: 7px; width: 60px; height: 36px; position: absolute; top: 8px; right: 8px; width: 40px; height: 40px;
background: none; border: 0; border-radius: 3px; cursor: pointer; outline: none; display: flex; align-items: center; justify-content: center;
} border: 0; border-radius: 50%; background: var(--primary); color: #fff;
#search button.submit::after { cursor: pointer; outline: none; transition: background .2s, transform .2s;
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);
} }
#search button.submit:hover { background: var(--primary-dark); transform: scale(1.05); }
#search button.submit:active { transform: scale(.96); }
#word { #word {
position: absolute; list-style: none; top: 55px; left: 0; width: 100%; margin: 0; position: absolute; list-style: none; top: 64px; left: 0; width: 100%; margin: 0;
background: #fff; border-radius: 5px; z-index: 20000; padding: 10px; background: #fff; border-radius: 16px; z-index: 20000; padding: 8px;
box-shadow: 0 0 10px #bbb; display: none; 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 {
#word li:hover { background: #eee; } 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 { .mylist {
list-style: none; margin: 16px 0; padding: 12px 20px; list-style: none; margin: 18px 0; padding: 16px 20px 14px;
background: #fff; border-radius: 15px; box-shadow: 0 0.5rem 0.625rem rgba(212,212,212,.3); background: var(--card); border-radius: var(--radius-lg); box-shadow: var(--shadow);
display: flex; flex-wrap: wrap; 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 { .mylist > li.title {
width: 100%; margin: 0 0 10px; padding: 0 0 8px 10px; height: auto; width: 100%; margin: 0 0 12px; padding: 0 0 10px 14px; height: auto;
font-size: 16px; font-weight: 600; line-height: 24px; font-size: 16px; font-weight: 600; line-height: 22px; color: #1f2733;
border-bottom: 1px solid #eee; position: relative; border-bottom: 1px solid var(--line); position: relative;
} }
.mylist > li.title::before { .mylist > li.title::before {
content: ""; position: absolute; left: 0; top: 3px; width: 4px; height: 18px; content: ""; position: absolute; left: 0; top: 3px; width: 4px; height: 17px;
background: var(--primary); border-radius: 2px; background: linear-gradient(180deg, var(--primary), #6f9bff);
border-radius: 2px;
} }
.mylist > li.site { margin: 2px; } .mylist > li.site { margin: 2px; }
.mylist li.site a { .mylist li.site a {
display: flex; flex-direction: column; align-items: center; width: 100px; padding: 10px 4px; display: flex; flex-direction: column; align-items: center; width: 104px;
border-radius: 10px; transition: background .2s; padding: 12px 6px 10px; border-radius: 14px;
transition: background .2s, transform .2s;
} }
.mylist li.site a:hover { background: #f0f1f2; } .mylist li.site a:hover { background: linear-gradient(180deg, #f7f9ff, var(--primary-soft)); transform: translateY(-3px); }
.mylist li.site .icon { width: 45px; height: 45px; border-radius: 10px; background: #fff; object-fit: contain; } .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 { .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; 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 { .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; 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 { .collections-title {
font-size: 16px; font-weight: 600; padding: 0 0 8px 10px; margin: 0 0 10px; font-size: 16px; font-weight: 600; color: #1f2733;
position: relative; padding: 0 0 10px 14px; margin: 0 0 14px; position: relative;
} }
.collections-title::before { .collections-title::before {
content: ""; position: absolute; left: 0; top: 3px; width: 4px; height: 18px; content: ""; position: absolute; left: 0; top: 3px; width: 4px; height: 17px;
background: var(--primary); border-radius: 2px; 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 { .tabs .tab {
border: none; background: rgba(255,255,255,.65); color: #444; cursor: pointer; border: 1px solid rgba(255, 255, 255, .9); background: rgba(255, 255, 255, .72);
padding: 7px 16px; border-radius: 18px; font-size: 14px; transition: all .2s; 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 { 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 {
.foot a { color: #8a8a8a; } 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); } .foot a:hover { color: var(--primary); }
/* ---------- 响应式 ---------- */ /* ---------- 响应式 ---------- */
@media (max-width: 767px) { @media (max-width: 767px) {
.container { margin-top: 24px; } .hero { padding: 28px 0 16px; }
.mylist { border-radius: 10px; padding: 10px 12px; } .hero .logo { width: 52px; height: 52px; border-radius: 15px; }
.mylist li.site a { width: 25%; } .hero h1 { font-size: 21px; }
.mylist li.site .icon, .mylist li.site .ico-letter { width: 40px; height: 40px; } .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; } .mylist li.site .name { font-size: 12px; }
} }
+142 -16
View File
@@ -1,7 +1,15 @@
/* 简航 SimpleNav —— 后台交互 */ /* 简航 SimpleNav —— 后台交互 */
(function () { (function () {
'use strict'; '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 ? '<img src="' + src + '" alt="">' : '<span class="ph">无</span>';
}
/* ---------- 自动获取网站信息 ---------- */ /* ---------- 自动获取网站信息 ---------- */
var fetchBtn = $('#btn-fetch'); var fetchBtn = $('#btn-fetch');
@@ -10,7 +18,7 @@
var url = ($('#f-url') || {}).value || ''; var url = ($('#f-url') || {}).value || '';
var hint = $('#fetch-hint'); var hint = $('#fetch-hint');
if (!url) { if (hint) hint.textContent = '请先填写网址'; return; } if (!url) { if (hint) hint.textContent = '请先填写网址'; return; }
if (hint) hint.textContent = '正在获取…'; if (hint) hint.textContent = '正在获取标题与高清图标…';
fetchBtn.disabled = true; fetchBtn.disabled = true;
fetch(fetchBtn.getAttribute('data-url') + '?url=' + encodeURIComponent(url)) fetch(fetchBtn.getAttribute('data-url') + '?url=' + encodeURIComponent(url))
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
@@ -19,23 +27,73 @@
if (!d.ok) { if (hint) hint.textContent = d.msg || '获取失败'; return; } if (!d.ok) { if (hint) hint.textContent = d.msg || '获取失败'; return; }
if ($('#f-name')) $('#f-name').value = d.name || ''; if ($('#f-name')) $('#f-name').value = d.name || '';
if ($('#f-short') && !$('#f-short').value) $('#f-short').value = d.short || 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 = '网络错误'; }); .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'); var resetBtn = $('#btn-reset');
if (resetBtn) { if (resetBtn) {
resetBtn.addEventListener('click', function () { 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 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 = ''; 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 () { btn.addEventListener('click', function () {
var d = btn.dataset; var d = btn.dataset;
$('#f-id').value = d.id; $('#f-id').value = d.id;
@@ -44,13 +102,19 @@
$('#f-name').value = d.name; $('#f-name').value = d.name;
$('#f-short').value = d.short; $('#f-short').value = d.short;
$('#f-icon').value = d.icon; $('#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'; $('#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' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}); });
}); });
/* ---------- 编辑板块 / 引擎 ---------- */ /* ---------- 编辑板块 / 引擎 ---------- */
document.querySelectorAll('.edit-group').forEach(function (btn) { $$('.edit-group').forEach(function (btn) {
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
var d = btn.dataset; var d = btn.dataset;
$('#g-id').value = d.id; $('#g-name').value = d.name; $('#g-id').value = d.id; $('#g-name').value = d.name;
@@ -61,7 +125,7 @@
var gReset = $('#g-reset'); 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; }); 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 () { btn.addEventListener('click', function () {
var d = btn.dataset; var d = btn.dataset;
$('#e-id').value = d.id; $('#e-name').value = d.name; $('#e-slug').value = d.slug; $('#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; $('#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'); var upBtn = $('#btn-update-all');
if (upBtn) { if (upBtn) {
upBtn.addEventListener('click', function () { upBtn.addEventListener('click', function () {
if (!confirm('将批量刷新所有网站的图标与名称(用户自定义简称不受影响),确定继续?')) return; if (!confirm('将批量刷新所有网站的名称与自动图标。\n用户自定义简称不受影响;标记为「自定义」的图标会被跳过。\n确定继续?')) return;
var base = upBtn.getAttribute('data-url'); 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; upBtn.disabled = true;
function step() { 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 (r) { return r.json(); })
.then(function (d) { .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; upBtn.textContent = '⟳ 更新中 ' + Math.min(offset, total) + '/' + total;
if (!d.done) { step(); } if (!d.done) { step(); }
else { else {
upBtn.disabled = false; upBtn.disabled = false;
upBtn.textContent = '⟳ 全部更新(图标+名称)'; upBtn.textContent = '⟳ 全部更新(名称 + 自动图标)';
alert('更新完成:成功 ' + updated + ',失败/无变化 ' + failed + ',共 ' + total + ' 个网站。'); alert('更新完成:\n成功 ' + updated + ' 个\n跳过(自定义图标)' + skipped + ' 个\n失败/无变化 ' + failed + ' 个\n共 ' + total + ' 个网站。');
location.reload(); location.reload();
} }
}) })
.catch(function () { upBtn.disabled = false; upBtn.textContent = '⟳ 全部更新(图标+名称)'; alert('更新中断,请重试。'); }); .catch(function () { upBtn.disabled = false; upBtn.textContent = '⟳ 全部更新(名称 + 自动图标)'; alert('更新中断,请重试。'); });
} }
step(); step();
}); });
+30 -6
View File
@@ -15,9 +15,8 @@
var param = el.getAttribute('data-param'); var param = el.getAttribute('data-param');
var slug = el.getAttribute('data-slug'); var slug = el.getAttribute('data-slug');
var ph = el.getAttribute('data-placeholder') || '搜索'; 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); } if (input) { input.setAttribute('name', param); input.setAttribute('placeholder', ph); }
form.setAttribute('data-slug', slug);
hideWord(); hideWord();
} }
tabs.forEach(function (el) { tabs.forEach(function (el) {
@@ -34,9 +33,9 @@
if (!word) return; if (!word) return;
if (!items || !items.length) { hideWord(); return; } if (!items || !items.length) { hideWord(); return; }
word.innerHTML = items.map(function (t) { word.innerHTML = items.map(function (t) {
var d = document.createElement('div'); var d = document.createElement('li');
d.textContent = t; d.textContent = t;
return '<li>' + d.innerHTML + '</li>'; return d.outerHTML;
}).join(''); }).join('');
word.style.display = 'block'; word.style.display = 'block';
} }
@@ -69,7 +68,7 @@
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
if (word && !word.contains(e.target) && e.target !== input) { hideWord(); } if (word && !word.contains(e.target) && e.target !== input) { hideWord(); }
var li = e.target.closest ? e.target.closest('#word li') : null; var li = e.target.closest ? e.target.closest('#word li') : null;
if (li) { if (li && input) {
input.value = li.textContent; input.value = li.textContent;
hideWord(); hideWord();
if (form) { form.submit(); } if (form) { form.submit(); }
@@ -80,11 +79,36 @@
var collTabs = document.querySelectorAll('#coll-tabs .tab'); var collTabs = document.querySelectorAll('#coll-tabs .tab');
collTabs.forEach(function (tab) { collTabs.forEach(function (tab) {
tab.addEventListener('click', function () { 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.classList.add('active');
tab.setAttribute('aria-selected', 'true');
document.querySelectorAll('.tab-panel').forEach(function (p) { p.classList.remove('active'); }); document.querySelectorAll('.tab-panel').forEach(function (p) { p.classList.remove('active'); });
var panel = document.getElementById(tab.getAttribute('data-target')); var panel = document.getElementById(tab.getAttribute('data-target'));
if (panel) { panel.classList.add('active'); } 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 });
});
})(); })();
+18
View File
@@ -16,6 +16,24 @@ define('DATA_PATH', APP_PATH . '/data'); // 数据库等敏感数据(
define('DB_PATH', DATA_PATH . '/nav.db'); define('DB_PATH', DATA_PATH . '/nav.db');
define('ICON_PATH', APP_PATH . '/uploads/icons'); // 抓取的网站图标(可公开访问) define('ICON_PATH', APP_PATH . '/uploads/icons'); // 抓取的网站图标(可公开访问)
define('ICON_URL', '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」与「放在根目录」两种部署。 * 自动探测应用根 URL,兼容「放在子目录 /simplenav」与「放在根目录」两种部署。
+10
View File
@@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS sites (
name TEXT NOT NULL DEFAULT '', -- 自动抓取的网站名称 name TEXT NOT NULL DEFAULT '', -- 自动抓取的网站名称
short_name TEXT NOT NULL DEFAULT '', -- 用户自定义简称(页面展示用) short_name TEXT NOT NULL DEFAULT '', -- 用户自定义简称(页面展示用)
icon TEXT NOT NULL DEFAULT '', -- 本地图标文件名 icon TEXT NOT NULL DEFAULT '', -- 本地图标文件名
icon_custom INTEGER NOT NULL DEFAULT 0, -- 1=用户自定义图标("全部更新"不再覆盖)
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
is_visible INTEGER NOT NULL DEFAULT 1, is_visible INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT '', created_at TEXT DEFAULT '',
@@ -71,6 +72,15 @@ CREATE TABLE IF NOT EXISTS users (
); );
SQL); 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; $isEmpty = (int)$pdo->query('SELECT COUNT(*) FROM groups')->fetchColumn() === 0;
+533 -101
View File
@@ -26,14 +26,14 @@ function norm_url(string $url): string
return $url; return $url;
} }
/** 取主域名(用于 favicon 与文件名) */ /** 取主域名(用于图标文件名与首字母回退) */
function domain_of(string $url): string function domain_of(string $url): string
{ {
$host = parse_url(norm_url($url), PHP_URL_HOST); $host = parse_url(norm_url($url), PHP_URL_HOST);
return $host ? preg_replace('/^www\./i', '', $host) : ''; return $host ? preg_replace('/^www\./i', '', $host) : '';
} }
/** curl 抓取文本(带 UA、超时、跟随跳转、限长) */ /** curl 抓取(带 UA、超时、跟随跳转) */
function http_get(string $url, int $maxBytes = 300000, int $timeout = 8): ?array function http_get(string $url, int $maxBytes = 300000, int $timeout = 8): ?array
{ {
if (!function_exists('curl_init')) { 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_MAXREDIRS => 5,
CURLOPT_TIMEOUT => $timeout, CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 5, 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_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => 0, CURLOPT_SSL_VERIFYHOST => 0,
CURLOPT_ENCODING => '', CURLOPT_ENCODING => '',
CURLOPT_HTTPHEADER => ['Accept-Language: zh-CN,zh;q=0.9,en;q=0.8'],
]); ]);
$body = curl_exec($ch); $body = curl_exec($ch);
$type = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE); $type = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$eff = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); $eff = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
$code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_errno($ch); $err = curl_errno($ch);
curl_close($ch); curl_close($ch);
if ($err || $body === false) { if ($err || $body === false || $code >= 400) {
return null; return null;
} }
return ['body' => $body, 'url' => $eff ?: $url, 'type' => $type]; 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 function fetch_site_meta(string $url): array
{ {
$url = norm_url($url); $url = norm_url($url);
$out = ['title' => '', 'name' => '', 'icon' => '', 'final_url' => $url]; $out = ['title' => '', 'name' => '', 'icon' => '', 'icons' => [], 'final_url' => $url];
$resp = http_get($url); $resp = http_get($url);
if ($resp === null) { if ($resp === null) {
$out['icons'] = array_merge(third_party_icon_urls($url), default_icon_candidates($url));
$out['icon'] = $out['icons'][0] ?? '';
return $out; return $out;
} }
$html = $resp['body'];
$final = $resp['url']; $html = ensure_utf8((string)$resp['body']);
$final = $resp['url'] !== '' ? $resp['url'] : $url;
$out['final_url'] = $final; $out['final_url'] = $final;
// 编码:优先按 <meta charset> 转换到 UTF-8 // ---- 标题 / 名称 ----
$html = ensure_utf8($html);
// <title>
if (preg_match('#<title[^>]*>(.*?)</title>#is', $html, $m)) { if (preg_match('#<title[^>]*>(.*?)</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 优先作为"名称" $out['name'] = meta_content($html, 'og:site_name');
if (preg_match('#<meta[^>]+property=["\']og:site_name["\'][^>]+content=["\']([^"\']+)#i', $html, $m)) { if ($out['name'] === '' && $out['title'] !== '') {
$out['name'] = trim(html_entity_decode($m[1], ENT_QUOTES, 'UTF-8'));
} elseif ($out['title'] !== '') {
// 用标题的首段(按分隔符切分)作为名称 // 用标题的首段(按分隔符切分)作为名称
$t = preg_split('#[\|\-_—·::]#u', $out['title']); $seg = preg_split('#[\|\-_—·::]#u', $out['title']);
$out['name'] = trim($t[0]); $out['name'] = trim($seg[0] ?? '');
} }
// 图标:<link rel="...icon..."> // ---- 图标候选(按清晰度从高到低)----
$icon = ''; // 1. manifest 里的大图标(常见 512/192)
if (preg_match_all('#<link[^>]+>#i', $html, $links)) { // 2. apple-touch-icon(常见 180)
foreach ($links[0] as $tag) { // 3. <link rel="icon">,按 sizes 降序(SVG 视为最高清)
if (preg_match('#rel=["\'][^"\']*icon[^"\']*["\']#i', $tag)) { // 4. 联网搜索:第三方图标聚合服务(仅当上面都没拿到高清图时才会用到)
if (preg_match('#href=["\']([^"\']+)["\']#i', $tag, $hm)) { // 5. 兜底:站点自身 /favicon.ico
$cand = html_entity_decode($hm[1], ENT_QUOTES, 'UTF-8'); [$links, $manifestUrl] = parse_icon_links($html, $final);
// 优先 apple-touch-icon(通常更大更清晰) $icons = [];
if (stripos($tag, 'apple-touch') !== false) { if ($manifestUrl !== '') {
$icon = $cand; foreach (manifest_icons($manifestUrl) as $u) {
break; $icons[] = $u;
} }
if ($icon === '') { }
$icon = $cand; 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,偶有超时
];
}
/** 取 <meta property|name|itemprop="key" content="..."> 的值(属性顺序无关) */
function meta_content(string $html, string $key): string
{
if (!preg_match_all('#<meta\b[^>]*>#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 '';
}
/**
* 解析页面里的 <link> 图标声明。
* @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('#<link\b[^>]*>#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); usort($rel, static function ($a, $b) { return $b['size'] <=> $a['size']; });
} else {
$p = parse_url($final); $list = [];
if (!empty($p['scheme']) && !empty($p['host'])) { foreach ($apple as $h) { $list[] = absolutize($h, $base); }
$out['icon'] = $p['scheme'] . '://' . $p['host'] . '/favicon.ico'; foreach ($rel as $r) { $list[] = absolutize($r['url'], $base); }
} return [$list, $manifest === '' ? '' : absolutize($manifest, $base)];
}
return $out;
} }
/** 读取 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, '<svg') !== false
|| strpos($head, '<?xml') === 0;
if ($isSvg) {
if (strlen($bin) > 512 * 1024
|| preg_match('#<script|<foreignObject|onload\s*=|javascript:#i', $bin)) {
@unlink($tmp);
return null;
}
if (@file_put_contents($tmp, $bin) === false) {
@unlink($tmp);
return null;
}
return ['tmp' => $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)), '<svg') !== false) {
if (preg_match('#<script|<foreignObject|onload\s*=|javascript:#i', $binary)) {
return '';
}
$md5 = md5($key);
$file = $md5 . '.svg';
@unlink(ICON_PATH . '/' . $md5 . '.png');
@file_put_contents(ICON_PATH . '/' . $file, $binary);
return is_file(ICON_PATH . '/' . $file) ? $file : '';
}
if (!function_exists('imagecreatefromstring')) {
return '';
}
$im = @imagecreatefromstring($binary);
if ($im === false) {
return '';
}
$w = imagesx($im);
$h = imagesy($im);
if ($w < 8 || $h < 8) {
imagedestroy($im);
return '';
}
$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);
$md5 = md5($key);
$file = $md5 . '.png';
$ok = imagepng($canvas, ICON_PATH . '/' . $file, 6);
imagedestroy($canvas);
imagedestroy($im);
if ($ok === false) {
return '';
}
@unlink(ICON_PATH . '/' . $md5 . '.svg');
return $file;
}
/* =========================================================================
* 杂项
* ========================================================================= */
/** 把可能为相对路径的 URL 补全为绝对 URL */ /** 把可能为相对路径的 URL 补全为绝对 URL */
function absolutize(string $href, string $base): string function absolutize(string $href, string $base): string
{ {
// 有些页面把链接写成 JSON 转义形式(https:\/\/x\/y.ico),先还原
$href = str_replace('\\/', '/', trim($href));
if (preg_match('#^https?://#i', $href)) { if (preg_match('#^https?://#i', $href)) {
return $href; return $href;
} }
@@ -166,67 +595,70 @@ function ensure_utf8(string $s): string
return $s; return $s;
} }
/**
* 下载图标并保存到本地(PNG,64x64,透明背景)。
* @return string 本地文件名(如 ab12cd.png),失败返回 ''
*/
function download_icon(string $iconUrl, string $key, int $size = 64): string
{
if ($iconUrl === '') {
return '';
}
if (!is_dir(ICON_PATH)) {
@mkdir(ICON_PATH, 0755, true);
}
$resp = http_get($iconUrl, 500000, 8);
if ($resp === null || $resp['body'] === '') {
return '';
}
$bin = $resp['body'];
$name = md5($key) . '.png';
$dest = ICON_PATH . '/' . $name;
// 用 GD 统一转成 PNG(顺带裁成正方形、缩放)
if (function_exists('imagecreatefromstring')) {
$im = @imagecreatefromstring($bin);
if ($im !== false) {
$w = imagesx($im);
$h = imagesy($im);
$side = min($w, $h);
$square = imagecreatetruecolor($size, $size);
imagealphablending($square, false);
imagesavealpha($square, true);
$trans = imagecolorallocatealpha($square, 0, 0, 0, 127);
imagefill($square, 0, 0, $trans);
$sx = (int)(($w - $side) / 2);
$sy = (int)(($h - $side) / 2);
imagecopyresampled($square, $im, 0, 0, $sx, $sy, $size, $size, $side, $side);
imagepng($square, $dest);
imagedestroy($square);
imagedestroy($im);
return $name;
}
}
// 无 GD:原样保存(按 content-type 猜扩展名)
$ext = 'ico';
if (stripos($resp['type'], 'png') !== false) {
$ext = 'png';
} elseif (stripos($resp['type'], 'svg') !== false) {
$ext = 'svg';
} elseif (stripos($resp['type'], 'jpeg') !== false || stripos($resp['type'], 'jpg') !== false) {
$ext = 'jpg';
} elseif (stripos($resp['type'], 'gif') !== false) {
$ext = 'gif';
}
$name = md5($key) . '.' . $ext;
@file_put_contents(ICON_PATH . '/' . $name, $bin);
return is_file(ICON_PATH . '/' . $name) ? $name : '';
}
/** 图标显示地址(为空时返回 '',前端回退到首字母块) */ /** 图标显示地址(为空时返回 '',前端回退到首字母块) */
function icon_src(string $file): string function icon_src(string $file): string
{ {
return $file === '' ? '' : url(ICON_URL . '/' . $file); static $cache = [];
if ($file === '') {
return '';
}
if (!isset($cache[$file])) {
$abs = ICON_PATH . '/' . $file;
$cache[$file] = is_file($abs) ? (string)filemtime($abs) : '0';
}
// 带版本号:图标被重新抓取后文件名不变,没版本号浏览器会一直用旧图
return url(ICON_URL . '/' . $file) . '?v=' . $cache[$file];
}
/**
* 动态页面统一发「不要缓存」响应头。
*
* 为什么需要 —— 2026-09-28 实测搞清楚的两层缓存:
*
* ① 浏览器缓存:本站在此之前完全没有发 Cache-Control / Expires / ETag /
* Last-Modified,浏览器只能自己「猜」缓存策略,于是后台改了设置、前台
* 刷新却还是旧页面。上面那几个头就是治它的(这层已彻底解决)。
*
* ② 服务器侧页面缓存:宝塔面板的「网站加速」**Apache(mod_lua) 版**
* (脚本 /www/server/speed/httpd_speed.lua,后端 memcached 127.0.0.1:11211,
* 站点配置 /www/server/speed/config.lua 里 n.yangyq.net 这节,expire=30 秒)。
* 它的规则(读源码 + 线上实测):只缓存 GET + 200 + Content-Type 含 html + ≤1MB;
* 图片/CSS/JS 等扩展名直接跳过;缓存键 = host:port + 完整 URI(含 query) + _pc|_mobile。
* ⚠️ 它本意是「响应头 Cache-Control 含 no-cache 就不写入」,**但实测读不到
* FastCGI 后端返回的 Cache-Control**(存下来的副本里根本没这个头),所以光发
* no-store 挡不住它 —— 用 curl 连打同一 URL 会 HIT,HIT,HIT…,隔 30 秒才 MISS。
* 它唯一认的信号是**请求** cookie `X_CACHE=1`(本来是给登录用户打的标记)。
* → 根治靠根目录 .htaccess 里的 `RequestHeader` 在请求侧注入该标记(已实测:
* 任何访客都是 X-Cache: MISS,彻底不吃那 30 秒缓存)。
* → 下面这颗 cookie 是**第二道保险**:在 Nginx 等不读 .htaccess 的环境里顶上。
* 由 config.php 的 BYPASS_PAGE_CACHE 控制(false 则只发头、接受 ≤30 秒窗口)。
*/
function send_no_cache(): void
{
if (headers_sent()) {
return;
}
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
// 绕开服务器侧整页缓存(详见本函数顶部注释 ②)
if (defined('BYPASS_PAGE_CACHE') && BYPASS_PAGE_CACHE && (string)($_COOKIE['X_CACHE'] ?? '') !== '1') {
setcookie('X_CACHE', '1', [
'expires' => 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;
} }
/** 名称首字配色(无图标时的回退头像底色) */ /** 名称首字配色(无图标时的回退头像底色) */
+56 -38
View File
@@ -1,12 +1,13 @@
<?php <?php
/** /**
* 简航 SimpleNav —— 前台首页 * 简航 SimpleNav —— 前台首页
* 顶部:搜索引擎切换 + 搜索框(带联想下拉) * 顶部:品牌 + 搜索引擎切换 + 搜索框(带联想下拉)
* 中部:常用 / 工具等网格板块 * 中部:常用 / 工具等网格板块
* 下部:合集(标签页形式,点标签平铺该合集全部链接) * 下部:合集(标签页形式,点标签平铺该合集全部链接)
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/functions.php'; require_once __DIR__ . '/functions.php';
send_no_cache();
$pdo = db(); $pdo = db();
@@ -23,6 +24,8 @@ foreach ($engines as $en) {
} }
if ($default === null) { $default = $engines[0] ?? null; } if ($default === null) { $default = $engines[0] ?? null; }
$primary = setting('primary_color', '#2F6BF7');
function sites_of(int $gid): array function sites_of(int $gid): array
{ {
$st = db()->prepare("SELECT * FROM sites WHERE group_id=? AND is_visible=1 ORDER BY sort_order, id"); $st = db()->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'])); 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']);
?>
<li class="site">
<a href="<?= e($s['url']) ?>" target="_blank" rel="noopener" title="<?= e($s['name'] ?: $label) ?>">
<?php if ($src): ?>
<img class="icon" src="<?= e($src) ?>" alt="" loading="lazy">
<?php else: ?>
<span class="ico-letter" style="background:<?= e(letter_color($label)) ?>"><?= e(mb_substr($label, 0, 1)) ?></span>
<?php endif; ?>
<span class="name"><?= e($label) ?></span>
</a>
</li>
<?php
}
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
@@ -41,21 +63,32 @@ function site_label(array $s): string
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e(site_name()) ?> · <?= e(setting('subtitle', '个人网站导航')) ?></title> <title><?= e(site_name()) ?> · <?= e(setting('subtitle', '个人网站导航')) ?></title>
<meta name="theme-color" content="<?= e(setting('primary_color', '#2F6BF7')) ?>"> <meta name="description" content="<?= e(site_name()) ?> —— 个人网站导航">
<link rel="icon" href="<?= e(url('favicon.ico')) ?>" sizes="any"> <meta name="theme-color" content="<?= e($primary) ?>">
<link rel="icon" type="image/png" sizes="32x32" href="<?= e(url('assets/icons/icon-32.png')) ?>"> <link rel="icon" href="<?= e(asset('favicon.ico')) ?>" sizes="any">
<link rel="icon" type="image/png" sizes="16x16" href="<?= e(url('assets/icons/icon-16.png')) ?>"> <link rel="icon" type="image/png" sizes="32x32" href="<?= e(asset('assets/icons/icon-32.png')) ?>">
<link rel="apple-touch-icon" sizes="180x180" href="<?= e(url('apple-touch-icon.png')) ?>"> <link rel="icon" type="image/png" sizes="16x16" href="<?= e(asset('assets/icons/icon-16.png')) ?>">
<link rel="manifest" href="<?= e(url('site.webmanifest')) ?>"> <link rel="apple-touch-icon" sizes="180x180" href="<?= e(asset('apple-touch-icon.png')) ?>">
<link rel="stylesheet" href="<?= e(url('assets/css/style.css')) ?>"> <link rel="manifest" href="<?= e(asset('site.webmanifest')) ?>">
<link rel="stylesheet" href="<?= e(asset('assets/css/style.css')) ?>">
<?php if ($primary !== '#2F6BF7'): ?>
<style>:root { --primary: <?= e($primary) ?>; }</style>
<?php endif; ?>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<!-- 顶部品牌区 -->
<header class="hero">
<img class="logo" src="<?= e(asset('assets/img/logo.svg')) ?>" alt="<?= e(site_name()) ?>">
<h1><?= e(site_name()) ?></h1>
<p><?= e(setting('subtitle', '个人网站导航')) ?></p>
</header>
<!-- 顶部搜索 --> <!-- 顶部搜索 -->
<div id="search" class="s-search"> <div id="search" class="s-search">
<div class="search-type" id="engine-tabs"> <div class="search-type" id="engine-tabs">
<?php foreach ($engines as $i => $en): ?> <?php foreach ($engines as $en): ?>
<label class="s-engine<?= $default && $en['slug'] === $default['slug'] ? ' active' : '' ?>" <label class="s-engine<?= $default && $en['slug'] === $default['slug'] ? ' active' : '' ?>"
data-search="<?= e($en['search_url']) ?>" data-search="<?= e($en['search_url']) ?>"
data-param="<?= e($en['param']) ?>" data-param="<?= e($en['param']) ?>"
@@ -73,7 +106,12 @@ function site_label(array $s): string
<form id="search-form" action="<?= e($default['search_url'] ?? 'https://cn.bing.com/search') ?>" method="get" target="<?= setting('open_blank', '1') === '1' ? '_blank' : '_self' ?>" rel="noopener"> <form id="search-form" action="<?= e($default['search_url'] ?? 'https://cn.bing.com/search') ?>" method="get" target="<?= setting('open_blank', '1') === '1' ? '_blank' : '_self' ?>" rel="noopener">
<input type="text" id="search-text" name="<?= e($default['param'] ?? 'q') ?>" <input type="text" id="search-text" name="<?= e($default['param'] ?? 'q') ?>"
placeholder="<?= e(($default['name'] ?? '搜索') . '搜索') ?>" autocomplete="off"> placeholder="<?= e(($default['name'] ?? '搜索') . '搜索') ?>" autocomplete="off">
<button class="submit" type="submit" aria-label="搜索"></button> <button class="submit" type="submit" aria-label="搜索">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<path d="M20 20l-3.6-3.6"></path>
</svg>
</button>
<ul id="word"></ul> <ul id="word"></ul>
</form> </form>
</div> </div>
@@ -82,18 +120,7 @@ function site_label(array $s): string
<?php foreach ($gridGroups as $g): $items = sites_of((int)$g['id']); if (!$items) continue; ?> <?php foreach ($gridGroups as $g): $items = sites_of((int)$g['id']); if (!$items) continue; ?>
<ul class="mylist"> <ul class="mylist">
<li class="title"><?= e($g['name']) ?></li> <li class="title"><?= e($g['name']) ?></li>
<?php foreach ($items as $s): $label = site_label($s); $src = icon_src($s['icon']); ?> <?php foreach ($items as $s) { site_tile($s); } ?>
<li class="site">
<a href="<?= e($s['url']) ?>" target="_blank" rel="noopener" title="<?= e($s['name'] ?: $label) ?>">
<?php if ($src): ?>
<img class="icon" src="<?= e($src) ?>" alt="" loading="lazy">
<?php else: ?>
<span class="ico-letter" style="background:<?= e(letter_color($label)) ?>"><?= e(mb_substr($label, 0, 1)) ?></span>
<?php endif; ?>
<span class="name"><?= e($label) ?></span>
</a>
</li>
<?php endforeach; ?>
</ul> </ul>
<?php endforeach; ?> <?php endforeach; ?>
@@ -101,26 +128,17 @@ function site_label(array $s): string
<?php if ($tabGroups): ?> <?php if ($tabGroups): ?>
<div class="collections"> <div class="collections">
<div class="collections-title">合集</div> <div class="collections-title">合集</div>
<div class="tabs" id="coll-tabs"> <div class="tabs" id="coll-tabs" role="tablist">
<?php foreach ($tabGroups as $i => $g): ?> <?php foreach ($tabGroups as $i => $g): ?>
<button type="button" class="tab<?= $i === 0 ? ' active' : '' ?>" data-target="coll-<?= (int)$g['id'] ?>"><?= e($g['name']) ?></button> <button type="button" class="tab<?= $i === 0 ? ' active' : '' ?>" role="tab"
aria-selected="<?= $i === 0 ? 'true' : 'false' ?>"
data-target="coll-<?= (int)$g['id'] ?>"><?= e($g['name']) ?></button>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<div class="tab-panels"> <div class="tab-panels">
<?php foreach ($tabGroups as $i => $g): $items = sites_of((int)$g['id']); ?> <?php foreach ($tabGroups as $i => $g): $items = sites_of((int)$g['id']); ?>
<ul class="mylist tab-panel<?= $i === 0 ? ' active' : '' ?>" id="coll-<?= (int)$g['id'] ?>"> <ul class="mylist tab-panel<?= $i === 0 ? ' active' : '' ?>" id="coll-<?= (int)$g['id'] ?>" role="tabpanel">
<?php foreach ($items as $s): $label = site_label($s); $src = icon_src($s['icon']); ?> <?php foreach ($items as $s) { site_tile($s); } ?>
<li class="site">
<a href="<?= e($s['url']) ?>" target="_blank" rel="noopener" title="<?= e($s['name'] ?: $label) ?>">
<?php if ($src): ?>
<img class="icon" src="<?= e($src) ?>" alt="" loading="lazy">
<?php else: ?>
<span class="ico-letter" style="background:<?= e(letter_color($label)) ?>"><?= e(mb_substr($label, 0, 1)) ?></span>
<?php endif; ?>
<span class="name"><?= e($label) ?></span>
</a>
</li>
<?php endforeach; ?>
</ul> </ul>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
@@ -135,6 +153,6 @@ function site_label(array $s): string
</div> </div>
<script>window.SIMPLENAV = { suggest: "<?= e(url('suggest.php')) ?>" };</script> <script>window.SIMPLENAV = { suggest: "<?= e(url('suggest.php')) ?>" };</script>
<script src="<?= e(url('assets/js/app.js')) ?>"></script> <script src="<?= e(asset('assets/js/app.js')) ?>"></script>
</body> </body>
</html> </html>
+4 -2
View File
@@ -7,6 +7,7 @@ require_once __DIR__ . '/functions.php';
if (session_status() !== PHP_SESSION_ACTIVE) { if (session_status() !== PHP_SESSION_ACTIVE) {
session_start(); session_start();
} }
send_no_cache();
$hasAdmin = (int)db()->query('SELECT COUNT(*) FROM users')->fetchColumn() > 0; $hasAdmin = (int)db()->query('SELECT COUNT(*) FROM users')->fetchColumn() > 0;
$err = ''; $err = '';
@@ -45,8 +46,9 @@ $title = $hasAdmin ? '登录' : '初始化管理员';
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($title) ?> · <?= e(site_name()) ?></title> <title><?= e($title) ?> · <?= e(site_name()) ?></title>
<link rel="stylesheet" href="<?= e(url('assets/css/style.css')) ?>"> <link rel="stylesheet" href="<?= e(asset('assets/css/style.css')) ?>">
<link rel="stylesheet" href="<?= e(url('assets/css/admin.css')) ?>"> <link rel="stylesheet" href="<?= e(asset('assets/css/admin.css')) ?>">
<link rel="icon" href="<?= e(asset('favicon.ico')) ?>" sizes="any">
</head> </head>
<body class="admin"> <body class="admin">
<div class="login-wrap"> <div class="login-wrap">
+1 -1
View File
@@ -8,7 +8,7 @@ declare(strict_types=1);
require_once __DIR__ . '/functions.php'; require_once __DIR__ . '/functions.php';
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store'); send_no_cache();
$slug = isset($_GET['slug']) ? preg_replace('/[^a-z0-9\-_]/i', '', (string)$_GET['slug']) : ''; $slug = isset($_GET['slug']) ? preg_replace('/[^a-z0-9\-_]/i', '', (string)$_GET['slug']) : '';
$q = isset($_GET['q']) ? trim((string)$_GET['q']) : ''; $q = isset($_GET['q']) ? trim((string)$_GET['q']) : '';