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:
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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」与「放在根目录」两种部署。
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 名称首字配色(无图标时的回退头像底色) */
|
/** 名称首字配色(无图标时的回退头像底色) */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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']) : '';
|
||||||
|
|||||||
Reference in New Issue
Block a user