后台上传站点图标:首页品牌图 + favicon + PWA 图标一起换

需求:后台新增设置,允许管理员上传自己的图标;上传后首页品牌图标与浏览器
标签页 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 无溢出、线上直连桌面+移动端几何全绿、
线上诊断页走通「上传→前台全换→复原」。
This commit is contained in:
2026-09-28 22:28:23 +08:00
parent 2dfecef116
commit 92eb9d90be
11 changed files with 653 additions and 14 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ function admin_layout(string $active, string $title, string $body): void
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($title) ?> · <?= e(site_name()) ?> 后台</title>
<link rel="icon" href="<?= e(asset('favicon.ico')) ?>" sizes="any">
<?= site_icon_head() ?>
<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; ?>
+52
View File
@@ -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');
+57
View File
@@ -367,6 +367,63 @@ else: // settings ?>
<div class="span2 form-actions"><button class="btn primary">保存设置</button></div>
</form>
</section>
<section class="card">
<div class="card-head"><h2>站点图标</h2></div>
<?php
$iconOn = site_icon_mode() === 'custom';
$iconPrev = site_icon_url('logo', 512);
$iconDirW = site_icon_dir_writable();
?>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" enctype="multipart/form-data" class="grid-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="site_icon_save">
<div class="span2 icon-field">
<div class="icon-field-preview big">
<span class="icon-preview big">
<img src="<?= e($iconPrev !== '' ? $iconPrev : url('assets/img/logo.svg')) ?>" alt="">
</span>
</div>
<div class="icon-field-body">
<div class="icon-field-title">
当前:<?= $iconOn ? '自定义图标' : '内置默认图标' ?>
<span class="pill" data-site-icon="<?= e(site_icon_mode()) ?>">
<?= $iconOn ? '自定义' : '默认' ?>
</span>
</div>
<div class="row icon-row2">
<input type="file" name="site_icon_file" accept="image/png,image/jpeg,image/gif,image/webp">
<button class="btn primary" type="submit">上传并应用</button>
</div>
<p class="hint">
上传后,<b>浏览器标签页图标(favicon)</b>与<b>首页顶部的品牌图标</b>会一起换成它,
「添加到手机主屏」的图标也一并更新。<br>
建议用<b>正方形</b>图片(至少 32×32,推荐 512×512 以上);非正方形会<b>居中裁剪</b>成正方形。
支持 PNG / JPG / GIF / WebP,单张限 5MB。
</p>
<?php if ($iconOn): ?>
<p class="hint" data-site-icon-files="<?= $iconDirW ? 'static' : 'inline' ?>">
<?= $iconDirW
? '已在 uploads/site/ 生成一整套尺寸(16/32/48/180/192/512 + PWA 自适应版)。'
: '站点目录不可写,整套尺寸已内置在本站数据里,由 site-icon.php 按需输出。' ?>
</p>
<?php endif; ?>
</div>
</div>
</form>
<?php if ($iconOn): ?>
<form method="post" action="<?= e(url('admin/actions.php')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="action" value="site_icon_reset">
<p class="hint">想换回内置图标:<button class="btn ghost" type="submit">恢复默认图标</button></p>
</form>
<?php endif; ?>
<p class="hint">
⚠️ 这与「网站列表里每个网站自己的图标」是<b>两回事</b>:这里是<b>本站自己</b>的图标(一个),
那里是导航格子里<b>每个目标网站</b>的图标(很多个)。改这里不会动到任何一个网站格子。
</p>
</section>
<section class="card">
<div class="card-head"><h2>修改管理员密码</h2></div>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="grid-form">