From 92eb9d90beafe49ee6e01a725926191a8b0610ee Mon Sep 17 00:00:00 2001 From: laoyang Date: Mon, 28 Sep 2026 22:28:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E4=B8=8A=E4=BC=A0=E7=AB=99?= =?UTF-8?q?=E7=82=B9=E5=9B=BE=E6=A0=87=EF=BC=9A=E9=A6=96=E9=A1=B5=E5=93=81?= =?UTF-8?q?=E7=89=8C=E5=9B=BE=20+=20favicon=20+=20PWA=20=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E4=B8=80=E8=B5=B7=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:后台新增设置,允许管理员上传自己的图标;上传后首页品牌图标与浏览器 标签页 favicon(以及「添加到手机主屏」图标)一起换成它。 要点: - 一张上传图服务端居中裁剪成正方形,生成 16/32/48/64/180/192/512 + maskable 192/512 整套尺寸,并手工拼装多帧 favicon.ico。 - 单一来源 site_icon_url() 统一取址;site_icon_head() 被首页/登录页/后台 三处共用;PWA manifest 改由 manifest.php 动态输出、maskable 也跟随 —— 三处防线共同杜绝「首页换了、标签页还是旧的」这类半生效。 - 目录可写则落静态文件(最快);不可写的保守环境整套压进设置、由 site-icon.php 按需输出,功能不因权限限制整块不可用。 - 与「网站列表里每个网站自己的图标」是两个维度:目录与设置键都分开, 清理时互不误伤。 顺手修掉两个真问题: - login.php / admin/_auth.php 各留了一行写死的 favicon,与新的 site_icon_head() 并存 → head 里两条 rel="icon",是「上传了却没换」的 温床。已删掉写死那两行。 - 自定义 favicon.ico 原先只塞 16+32 两帧,而内置是 16/32/48/64 四帧 —— 换图标反而退步。已补齐 icon-64 并统一为 4 帧。 - setting() 的请求内缓存导致「同请求写后再读」拿到旧值(真实坑): settings_cache() 支持强制重载 + settings_reload(),写入后立即失效。 验证:单测 44/44(含只读目录动态回退)、HTTP 56/56、e2e 174/0、 反向验证 5 变体全部被抓住(含「写死残留」与「ico 只塞两帧」两个新哨兵)、 真机几何 360/390/768/1440 无溢出、线上直连桌面+移动端几何全绿、 线上诊断页走通「上传→前台全换→复原」。 --- .gitignore | 8 +- admin/_auth.php | 2 +- admin/actions.php | 52 ++++++ admin/index.php | 57 +++++++ assets/css/admin.css | 8 + config.php | 38 ++++- functions.php | 379 +++++++++++++++++++++++++++++++++++++++++++ index.php | 12 +- login.php | 4 +- manifest.php | 56 +++++++ site-icon.php | 51 ++++++ 11 files changed, 653 insertions(+), 14 deletions(-) create mode 100644 manifest.php create mode 100644 site-icon.php diff --git a/.gitignore b/.gitignore index a7be932..995e3a4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,17 @@ # --------------------------------------------------------------------------- # 运行时数据(绝不入库) # data/ —— SQLite 数据库等敏感数据 -# uploads/icons/* —— 抓取/上传的网站图标 +# uploads/icons/* —— 抓取/上传的网站图标(每个目标网站一份) +# uploads/site/* —— 管理员上传的**本站**图标整套尺寸 +# ⚠️ 与 uploads/icons 是两件事,别合并成一条通配: +# 它们是不同维度(本站一个 vs 每站一个), +# 分开写才能各自加白名单、也便于日后单独清理。 # --------------------------------------------------------------------------- /data/ /uploads/icons/* !/uploads/icons/.gitkeep +/uploads/site/* +!/uploads/site/.gitkeep # --------------------------------------------------------------------------- # 部署脚本(与具体服务器、账号、路径绑定) diff --git a/admin/_auth.php b/admin/_auth.php index ae66b94..94bf0da 100644 --- a/admin/_auth.php +++ b/admin/_auth.php @@ -70,7 +70,7 @@ function admin_layout(string $active, string $title, string $body): void <?= e($title) ?> · <?= e(site_name()) ?> 后台 - + diff --git a/admin/actions.php b/admin/actions.php index 46386f6..e393a79 100644 --- a/admin/actions.php +++ b/admin/actions.php @@ -207,6 +207,58 @@ try { break; } + /* ---------- 站点整体图标(favicon + 首页品牌图标) ---------- */ + case 'site_icon_save': { + $back = 'admin/?p=settings'; + $f = $_FILES['site_icon_file'] ?? null; + if (!$f || empty($f['tmp_name']) || !is_uploaded_file((string)$f['tmp_name'])) { + flash('请选择要上传的图片', 'err'); + break; + } + if ((int)$f['error'] !== UPLOAD_ERR_OK) { + flash('上传失败(错误码 ' . (int)$f['error'] . ',可能是文件超过服务器上限)', 'err'); + break; + } + $size = (int)($f['size'] ?? 0); + if ($size <= 0) { + flash('上传的文件是空的', 'err'); + break; + } + if ($size > 5 * 1024 * 1024) { + flash('图片太大了(' . round($size / 1048576, 1) . 'MB),请压缩到 5MB 以内', 'err'); + break; + } + $bin = (string)@file_get_contents((string)$f['tmp_name']); + if ($bin === '') { + flash('读取上传文件失败', 'err'); + break; + } + // 只认图片:SVG 不收(矢量图当站点图标会带来 XSS 面,且栅格化各端不一致) + $im = gd_load($bin); + if ($im === null) { + flash('这不是一张能识别的图片(支持 PNG / JPG / GIF / WebP)', 'err'); + break; + } + $iw = imagesx($im); + $ih = imagesy($im); + imagedestroy($im); + + $err = save_site_logo($bin); + if ($err !== '') { + flash($err, 'err'); + } else { + flash('站点图标已更新(' . $iw . '×' . $ih + . ' → 已生成 favicon 与手机主屏图标等一整套尺寸)'); + } + break; + } + case 'site_icon_reset': { + $back = 'admin/?p=settings'; + reset_site_logo(); + flash('已恢复默认图标'); + break; + } + /* ---------- 设置 ---------- */ case 'settings_save': { $srcRaw = (string)($_POST['suggest_source'] ?? 'baidu'); diff --git a/admin/index.php b/admin/index.php index 16ae612..da39a09 100644 --- a/admin/index.php +++ b/admin/index.php @@ -367,6 +367,63 @@ else: // settings ?>
+ +
+

站点图标

+ +
+ + +
+
+ + + +
+
+
+ 当前: + + + +
+
+ + +
+

+ 上传后,浏览器标签页图标(favicon)与首页顶部的品牌图标会一起换成它, + 「添加到手机主屏」的图标也一并更新。
+ 建议用正方形图片(至少 32×32,推荐 512×512 以上);非正方形会居中裁剪成正方形。 + 支持 PNG / JPG / GIF / WebP,单张限 5MB。 +

+ +

+ +

+ +
+
+
+ +
+ + +

想换回内置图标:

+
+ +

+ ⚠️ 这与「网站列表里每个网站自己的图标」是两回事:这里是本站自己的图标(一个), + 那里是导航格子里每个目标网站的图标(很多个)。改这里不会动到任何一个网站格子。 +

+
+

修改管理员密码

diff --git a/assets/css/admin.css b/assets/css/admin.css index 9d66ecd..de4b8b5 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -103,6 +103,14 @@ body.admin { background: #eef0f3; color: #333; font-size: 14px; } } .icon-preview img { width: 100%; height: 100%; object-fit: contain; background: #fff; } .icon-preview .ph { color: #bbb; font-size: 12px; } +/* 站点整体图标:比格子里的图标大一号,看得清 */ +.icon-preview.big { width: 96px; height: 96px; border-radius: 20px; } +.icon-field-preview.big { flex: 0 0 96px; } +.pill { + display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px; + font-weight: 400; background: #eef2f7; color: #667085; vertical-align: 1px; +} +.pill[data-site-icon="custom"] { background: #e8f1fe; color: #2456cc; } .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] { diff --git a/config.php b/config.php index af0930b..2771fd5 100644 --- a/config.php +++ b/config.php @@ -18,6 +18,17 @@ define('ICON_PATH', APP_PATH . '/uploads/icons'); // 抓取的网站图标(可 define('ICON_URL', 'uploads/icons'); // 相对站点根 define('ICON_STORE', 128); // 图标统一存成 128×128(高清屏下 45~64px 显示仍锐利) +/** + * 「站点整体图标」(浏览器标签页 favicon + 首页品牌图标)—— 用户上传后自动生成的一整套尺寸。 + * + * ⚠️ 与上面的 ICON_PATH 是**两件事**,目录也刻意分开: + * ICON_PATH = 导航格子里**某个网站**自己的小图标,一个网站一份(键 = icon_key(网址)); + * SITE_ICON_PATH = **本站自己**的图标,全局唯一(键 = 随机 16 位 hex)。 + * 混在一起的话,清理图标目录时两边会互相误伤。 + */ +define('SITE_ICON_PATH', APP_PATH . '/uploads/site'); +define('SITE_ICON_URL', 'uploads/site'); + /** * 是否「由 PHP 再发一颗」绕开服务器侧页面缓存的标记 cookie。 * @@ -60,11 +71,19 @@ function site_name(): string return setting('site_name', APP_NAME); } -/** 读取设置项 */ -function setting(string $key, string $default = ''): string +/** + * 设置表缓存(可失效)。 + * + * ⚠️ 为什么必须能失效:这个缓存只在**首次读取**时查库,之后整进程复用。 + * 后台「保存设置 → 重定向」是两次请求,所以以前没暴露问题;但只要有**同一次请求里 + * 先写设置、再读回来**的路径(上传站点图标就是:写完马上要用新键拼 URL), + * 就会读到写之前的旧值 —— 表现为「上传成功但图标没变」。 + * 任何写入设置的地方,写完都要调 settings_reload()。 + */ +function settings_cache(bool $reload = false): array { static $cache = null; - if ($cache === null) { + if ($cache === null || $reload) { $cache = []; try { foreach (db()->query('SELECT k, v FROM settings') as $row) { @@ -74,5 +93,18 @@ function setting(string $key, string $default = ''): string $cache = []; } } + return $cache; +} + +/** 写入设置后调用,让本次请求后续的 setting() 读到新值 */ +function settings_reload(): void +{ + settings_cache(true); +} + +/** 读取设置项 */ +function setting(string $key, string $default = ''): string +{ + $cache = settings_cache(); return array_key_exists($key, $cache) && $cache[$key] !== '' ? $cache[$key] : $default; } diff --git a/functions.php b/functions.php index e8efd34..163d9eb 100644 --- a/functions.php +++ b/functions.php @@ -616,6 +616,385 @@ function save_custom_icon(string $binary, string $key): string return $file; } +/* ========================================================================= + * 站点整体图标(浏览器标签页 favicon + 首页品牌图标) + * + * 用户上传一张图 → 服务端生成**一整套**尺寸,前台各处的写法都不变: + * 静态优先(有就直出文件,兼容「部署目录只读」的保守环境) + * → 没有静态再回退到动态输出(新上传立刻生效,无需给站点目录写权限) + * 「一整套」是必须的:标签页 favicon 与「添加到手机主屏」的 PWA 图标读的是不同文件, + * 只换其中一个 = 半生效(用户会看到标签页新图、主屏图标还是旧的)。 + * ========================================================================= */ + +/** 自定义站点图标的存在性(唯一事实来源:上传成功后写的标记文件) */ +function site_icon_key(): string +{ + $k = setting('site_icon', ''); + return preg_match('/^[a-f0-9]{16,64}$/', $k) ? $k : ''; +} + +/** 自定义站点图标的磁盘目录(已做白名单,键只会是 hex) */ +function site_icon_dir(): string +{ + $k = site_icon_key(); + // ⚠️ 用 rtrim 去掉尾部斜杠再拼:否则会得到 ".../uploads/site//<键>", + // 检查文件存在时 PHP 会按原样拼路径并告警/找不到。 + return $k === '' ? '' : rtrim(SITE_ICON_PATH, '/\\') . '/' . $k; +} + +/** + * 站点图标地址(**单一来源**)。 + * + * 前台品牌区、各页 favicon、PWA manifest 全部走这里,任何一处都不许写死 —— + * 这正是过去「半生效」(只换标签页图标、主屏图标还是旧的)的来源。 + * + * @param string $name 文件基名(不带扩展名),如 'logo' / 'icon-192' / 'apple-touch-icon' + * 传 '' 表示要 favicon.ico + * @param int $size 该用途的期望尺寸,用于给静态文件挑扩展名(<32 用 .ico,其余 .png) + */ +function site_icon_url(string $name = '', int $size = 32, bool $bust = true): string +{ + $dir = site_icon_dir(); + if ($dir === '') { + return ''; + } + if ($name === '') { + $cand = [$dir . '/favicon.ico', $dir . '/icon-32.png']; + } elseif ($size < 32) { + $cand = [$dir . '/' . $name . '.ico', $dir . '/' . $name . '.png']; + } else { + $cand = [$dir . '/' . $name . '.png']; + } + foreach ($cand as $abs) { + if (is_file($abs)) { + // 静态直出:文件名带键(每次上传都换),浏览器不可能缓存到旧图; + // 再加 mtime 只为「同键被覆盖」这种手工操作兜底。 + $rel = SITE_ICON_URL . '/' . site_icon_key() . '/' . basename($abs); + return url($rel) . ($bust ? '?v=' . (string)filemtime($abs) : ''); + } + } + // 静态文件没落地 → 动态输出(前提是上传时已把整套序列化进 settings 的 site_icon_files) + if (!site_icon_files_ready()) { + return ''; + } + if ($name === '') { + return url('site-icon.php') . ($bust ? '?v=' . site_icon_key() : ''); + } + return url('site-icon.php') . '?n=' . rawurlencode($name) . ($bust ? '&v=' . site_icon_key() : ''); +} + +/** + * 动态回退所需的整套文件是否已备好。 + * + * 只在「上传那一刻站点目录不可写」时才会有内容;目录可写的常规情况下这里是空的 + * —— 刻意如此:setting() 每次请求都会把整张设置表读进内存,把 512px 的图塞进去 + * 会让每个页面都白白搬运一大串 base64。 + */ +function site_icon_files_ready(): bool +{ + // ⚠️ 刻意**不做** static 缓存:setting() 自己已有请求内缓存,这里再套一层 + // static 就会在「同一请求内先写后读」(上传成功后立刻渲染预览)时拿到旧值 + // —— 与当初 setting() 那个坑同源。代价只是一次 strlen,没必要冒这个险。 + $raw = setting('site_icon_files', ''); + return is_string($raw) && strlen($raw) > 64; +} + +/** 站点图标目录是否可写(决定走「静态优先」还是「动态回退」) */ +function site_icon_dir_writable(): bool +{ + $parent = SITE_ICON_PATH; + if (!is_dir($parent)) { + @mkdir($parent, 0755, true); + } + return is_dir($parent) && is_writable($parent); +} + +/* ---------- 图像处理小工具(本函数只服务于上传,不用看别处) ---------- */ + +/** 载入任意位图(png/jpg/gif/webp…)为 GD 资源,失败返回 null。 + * ⚠️ 不要写 `?object` 返回类型:PHP 7.4 的 GD 返回的是 **resource**,加类型会直接 TypeError。 */ +function gd_load(string $binary) +{ + if (!function_exists('imagecreatefromstring')) { + return null; + } + $im = @imagecreatefromstring($binary); + return $im === false ? null : $im; +} + +/** 把一张已载入的图居中裁剪成正方形并缩到 $size,返回 PNG 二进制(失败返回 '') */ +function gd_square_png($im, int $size): string +{ + $w = imagesx($im); + $h = imagesy($im); + $side = min($w, $h); + $ox = (int)(($w - $side) / 2); + $oy = (int)(($h - $side) / 2); + + $canvas = imagecreatetruecolor($size, $size); + imagealphablending($canvas, false); + imagesavealpha($canvas, true); + imagefill($canvas, 0, 0, imagecolorallocatealpha($canvas, 0, 0, 0, 127)); + imagecopyresampled($canvas, $im, 0, 0, $ox, $oy, $size, $size, $side, $side); + imagealphablending($canvas, true); + + ob_start(); + $ok = imagepng($canvas, null, 9); + $png = (string)ob_get_clean(); + imagedestroy($canvas); + return ($ok === false || $png === '') ? '' : $png; +} + +/** + * 把任意图**居中裁剪成正方形**并生成整套尺寸。 + * + * 为什么是「裁剪」而不是「留白」:站点图标是正方形用途(favicon / PWA 图标), + * 直接拉伸会把 logo 拉变形,留白则四周一圈透明、缩小后主体看不清。 + * + * @return array 文件基名 => 二进制(基名已含扩展名,如 'logo.png') + */ +function gd_site_icon_files(string $binary): array +{ + $im = gd_load($binary); + if ($im === null) { + return []; + } + if (imagesx($im) < 8 || imagesy($im) < 8) { + imagedestroy($im); + return []; + } + $out = []; + foreach (site_icon_plan() as $name => $size) { + $png = gd_square_png($im, $size); + if ($png !== '') { + $out[$name . '.png'] = $png; + } + } + // favicon.ico 里塞 16/32/48/64 四个尺寸。 + // ⚠️ 别只塞 16+32:内置 favicon.ico 本来就是 4 帧(16/32/48/64), + // 只给 2 帧会让「换自定义图标」反而**退步**(高分屏/任务栏取不到合适尺寸)。 + $frames = []; + foreach ([16, 32, 48, 64] as $sz) { + if (isset($out['icon-' . $sz . '.png'])) { + $frames[$sz] = $out['icon-' . $sz . '.png']; + } + } + $ico = make_ico($frames); + if ($ico !== '') { + $out['favicon.ico'] = $ico; + } + imagedestroy($im); + return $out; +} + +/** 用 PNG 二进制拼一个 .ico(微软 BMP 格式,32 位带 alpha);$pngs: 尺寸 => PNG 二进制 */ +function make_ico(array $pngs): string +{ + $frames = []; + foreach ($pngs as $png) { + $im = @imagecreatefromstring($png); + if ($im === false) { + continue; + } + $size = (int)imagesx($im); + if ($size < 1 || $size > 256 || imagesy($im) !== $size) { + imagedestroy($im); + continue; + } + // BITMAPINFOHEADER 40 字节:biSize biWidth biHeight biPlanes biBitCount + // biCompression biSizeImage biXPels biYPels biClrUsed biClrImportant + // (高度写 2 倍:ICO 的 DIB 把 XOR 位图与 AND 掩码叠在一起算高度) + $header = pack('VVVvvVVVVVV', 40, $size, $size * 2, 1, 32, 0, 0, 0, 0, 0, 0); + $xor = ''; + for ($y = $size - 1; $y >= 0; $y--) { + for ($x = 0; $x < $size; $x++) { + $rgba = imagecolorat($im, $x, $y); + $a = ($rgba >> 24) & 0x7F; + $xor .= chr($rgba & 0xFF) . chr(($rgba >> 8) & 0xFF) + . chr(($rgba >> 16) & 0xFF) . chr((int)round(255 - $a * 2.00787)); + } + } + imagedestroy($im); + $and = str_repeat("\x00", (int)($size / 8) * $size); // 1bpp 掩码,全 0 = 全部可见 + $frames[$size] = $header . $xor . $and; + } + if (!$frames) { + return ''; + } + ksort($frames); + $n = count($frames); + $ico = pack('vvv', 0, 1, $n); + $offset = 6 + $n * 16; + foreach ($frames as $size => $data) { + // 宽高字节:256 要写成 0 + $b = ($size >= 256) ? 0 : $size; + $ico .= pack('CCCCvvVV', $b, $b, 0, 0, 1, 32, strlen($data), $offset); + $offset += strlen($data); + } + foreach ($frames as $data) { + $ico .= $data; + } + return $ico; +} + +/** 整套图标清单:文件基名 => 尺寸 */ +function site_icon_plan(): array +{ + return [ + 'logo' => 512, // 首页品牌区(显示 60px,512 供高分屏) + 'icon-16' => 16, + 'icon-32' => 32, + 'icon-48' => 48, + 'icon-64' => 64, // 仅用于 favicon.ico 的第 4 帧(与内置资源集对齐) + 'icon-192' => 192, // PWA 常规 + 'icon-512' => 512, // PWA 常规 + 'icon-192-maskable' => 192, // PWA 自适应(Android 会裁成圆形) + 'icon-512-maskable' => 512, + 'apple-touch-icon' => 180, // iOS 加到主屏 + ]; +} + +/** + * 保存用户上传的站点图标:生成整套尺寸、落盘并写好设置。 + * + * 返回错误信息('' = 成功)。 + */ +function save_site_logo(string $binary): string +{ + if (!function_exists('imagecreatefromstring')) { + return '服务器缺少 GD 扩展,无法处理图片'; + } + $im = gd_load($binary); + if ($im === null) { + return '图片无法解析,请换一张 PNG / JPG / WebP'; + } + $w = imagesx($im); + $h = imagesy($im); + imagedestroy($im); + if ($w < 32 || $h < 32) { + return '图片太小了(' . $w . '×' . $h . '),请上传至少 32×32 的图'; + } + + $files = gd_site_icon_files($binary); + if (!isset($files['logo.png'])) { + return '图片处理失败,请换一张试试'; + } + + $key = bin2hex(random_bytes(8)); + $saved = false; + if (site_icon_dir_writable()) { + $dir = SITE_ICON_PATH . '/' . $key; + @mkdir($dir, 0755, true); + foreach ($files as $fname => $data) { + @file_put_contents($dir . '/' . $fname, $data); + } + $saved = is_file($dir . '/logo.png'); + } + + // 目录不可写(保守环境:站点目录只读)→ 把整套压进 settings,改走动态输出。 + // 这不是「可选的优化」:没有它,用户在有权限限制的服务器上上传会直接失败。 + $store = ''; + if (!$saved) { + $store = base64_encode((string)gzencode(serialize($files), 9)); + if (strlen($store) < 64 || strlen($store) > 8 * 1024 * 1024) { + return '站点目录不可写,且图片较大无法内置(请给 uploads/site 写权限)'; + } + } + + $pdo = db(); + $old = site_icon_key(); + $st = $pdo->prepare('INSERT INTO settings (k,v) VALUES (?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v'); + $st->execute(['site_icon', $key]); + $st->execute(['site_icon_files', $store]); + settings_reload(); // 同一次请求里后面还要用新键拼 URL(见 config.php 的说明) + if ($old !== '' && $old !== $key) { + delete_site_logo_files($old); // 换图就换键,旧目录整体清掉 + } + return ''; +} + +/** 恢复默认图标:清掉设置与文件(前台退回内置 logo/favicon) */ +function reset_site_logo(): void +{ + $old = site_icon_key(); + $pdo = db(); + $pdo->exec("DELETE FROM settings WHERE k IN ('site_icon','site_icon_files')"); + settings_reload(); // 同一次请求里后面还要判断「已回到默认」 + if ($old !== '') { + delete_site_logo_files($old); + } +} + +/** 删掉一套站点图标文件(只删 uploads/site/<16位hex>/ 这一层,绝不递归到外面) */ +function delete_site_logo_files(string $key): void +{ + if (!preg_match('/^[a-f0-9]{16,64}$/', $key)) { + return; // 白名单:键不合法就什么都不做 + } + $dir = SITE_ICON_PATH . '/' . $key; + if (!is_dir($dir)) { + return; + } + foreach ((array)@scandir($dir) as $f) { + if ($f === '.' || $f === '..') { + continue; + } + $abs = $dir . '/' . $f; + if (is_file($abs)) { + @unlink($abs); + } + } + @rmdir($dir); +} + +/** 取出整套图标(动态输出用);没有返回 [] */ +function site_icon_all(): array +{ + $raw = setting('site_icon_files', ''); + if ($raw === '' || strlen($raw) < 64) { + return []; + } + $bin = @base64_decode($raw, true); + if ($bin === false) { + return []; + } + $ser = @gzdecode($bin); + if ($ser === false) { + return []; + } + $arr = @unserialize($ser, ['allowed_classes' => false]); + return is_array($arr) ? $arr : []; +} + +/** 站点图标当前是「自定义」还是内置默认(供前台输出可断言标记) */ +function site_icon_mode(): string +{ + return site_icon_key() === '' ? 'default' : 'custom'; +} + +/** + * `` 里的图标声明(favicon 多尺寸 / apple-touch / manifest)。 + * + * **首页、登录页、后台三处共用这一个函数**:图标是全局的一件事, + * 任何一页单独写死就会出现「首页换了、登录页还是旧的」这种半生效。 + * 有自定义站点图标时整套走自定义,否则完全退回内置资源(= 改造前的行为)。 + */ +function site_icon_head(): string +{ + $pick = static function (string $name, int $size, string $fallback): string { + $u = site_icon_url($name, $size); + return $u !== '' ? $u : asset($fallback); + }; + $lines = [ + '', + '', + '', + '', + '', + ]; + return implode("\n", $lines); +} + /* ========================================================================= * 杂项 * ========================================================================= */ diff --git a/index.php b/index.php index 6298717..3ca9e81 100644 --- a/index.php +++ b/index.php @@ -89,11 +89,9 @@ function site_tile(array $s): void <?= e(site_name()) ?> · <?= e(setting('subtitle', '个人网站导航')) ?> - - - - - + + @@ -103,8 +101,8 @@ function site_tile(array $s): void
-
- +
+

diff --git a/login.php b/login.php index bc3248c..5c60613 100644 --- a/login.php +++ b/login.php @@ -48,12 +48,12 @@ $title = $hasAdmin ? '登录' : '初始化管理员'; <?= e($title) ?> · <?= e(site_name()) ?> - +