feat(nav): 导航站点支持自定义图标上传(含两处连带修复) #16
需求:网页版导航,新增/编辑导航地址时没有自定义图标功能。
## 功能
- 站点表单加 enctype=multipart/form-data,新增「自定义图标」控件:
40px 预览 + 选择图片 + 清除自定义图标(仅编辑且有自定义图时出现)
- 选择文件后本地即时预览(URL.createObjectURL,用完 revoke,不落 base64)
- 按文件内容判类型(finfo + <svg 探测),不信任扩展名;上限 1MB
- icon_custom 三态语义:不选文件=不动 icon 列;选文件=icon_custom=1;
勾清除=icon='' & icon_custom=0。普通改名不碰 icon 列,不会丢图
- saveUploaded() 改随机文件名。原实现按 md5(icon_key(url)) 命名,
单用户系统没事、多用户会互相覆盖
- 新增 Nav::iconFileInUse():图标文件按内容共享,删旧文件前先查还有没有
别的记录在用
- 图标从 uploads/ 目录取不到(线上 403),走 /nav_icon.php?f= 公开出口
## 连带修复 1:ICON_STORE 常量从未定义
SimpleNav 搬迁时漏搬 define('ICON_STORE', 128),NavFetch.php 第 667/739
行在用。PHP 8 抛 Error: Undefined constant "ICON_STORE",只要走到 GD
位图分支就 500。因为 saveUploaded() 一直是死代码(有定义无调用点),
这个错一直没被触发过。现补进 app/bootstrap.php(带 if (!defined) 保护)。
## 连带修复 2:22 处裸函数调用缺 self::
搬迁时把自由函数改成了 NavFetch 的静态方法,但类内部调用点没跟着改。
PHP 报 Error: Call to undefined function App\http_get() @ NavFetch.php:171。
这既是「全部更新」按钮 500 的根因,也是「图标一直抓不到」的真相 ——
整条抓取链路(http_get / attr_value / absolutize / third_party_icon_urls /
default_icon_candidates / meta_content / parse_icon_links / manifest_icons /
is_third_party_icon / looks_generated_tile / grab_icon / ensure_utf8)
一处都没跑起来过。
修后 CLI 实测 NavFetch::meta('https://www.baidu.com') 返回
name=[百度一下,你就知道]、icons=3、图标落盘 3818 字节。
## 验证
- php -l 5 个文件全过
- 端到端 21/0:真实上传 PNG → 列表出 img.nav-ico → nav_icon.php 返 200
+ PNG 头 → 编辑换图 → 清除 → 只改简称不丢图标 → 清理
- 语义验收:带自定义图的站点点「全部更新」后 icon 文件名不变、custom=1;
不传图的站点 custom=0(以服务端回报条数为证据,不看"未变")
This commit is contained in:
+93
-1
@@ -305,7 +305,9 @@ $setItems = [
|
||||
<div class="nav-reuse-f" id="navReuseFoot"></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<form method="post" action="<?= e(url('nav/save')) ?>" id="navSiteForm">
|
||||
<?php /* 🔴 工单 #16:必须带 enctype,否则 $_FILES['icon'] 收不到(表单里 file 字段会被忽略) */ ?>
|
||||
<form method="post" action="<?= e(url('nav/save')) ?>" id="navSiteForm"
|
||||
enctype="multipart/form-data">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="act" value="site_save">
|
||||
<?php if ($editSite): ?>
|
||||
@@ -345,6 +347,50 @@ $setItems = [
|
||||
value="<?= e((string)($editSite['short_name'] ?? '')) ?>"
|
||||
placeholder="页面上显示的字(优先于显示名)">
|
||||
</div>
|
||||
<?php /* 🔴 工单 #16(用户 2026-10-28):「增加导航地址的时候,怎么没有自定义图标
|
||||
功能啊,修改也没有。」
|
||||
⚠️ 三态语义,缺一个就会出 bug:
|
||||
· 不选文件、不勾清除 → 图标**一个字都不碰**(改名字不该丢图标)
|
||||
· 选了文件 → 换成新图,icon_custom=1(「全部更新」不再覆盖)
|
||||
· 勾了「清除」 → icon=''、icon_custom=0(回到抓取 / 字母块)
|
||||
⚠️ 预览图走 Nav::iconUrl()(即 /nav_icon.php 那个公开出口),
|
||||
不要直接给 uploads/ 的地址 —— 那个目录线上是 403。 */ ?>
|
||||
<div class="field full">
|
||||
<label>自定义图标</label>
|
||||
<?php
|
||||
$curIcon = \App\Nav::iconUrl((string)($editSite['icon'] ?? ''));
|
||||
$curLabel = \App\Nav::siteLabel((array)($editSite ?: ['name' => '', 'url' => '']));
|
||||
?>
|
||||
<div class="nav-icon-pick" id="navIconPick">
|
||||
<div class="nav-icon-prev" id="navIconPrev">
|
||||
<?php if ($curIcon !== ''): ?>
|
||||
<img src="<?= e($curIcon) ?>" alt="">
|
||||
<?php else: ?>
|
||||
<span class="nav-ico nav-ico-letter nav-ico-lg"
|
||||
style="background:<?= e(\App\Nav::letterColor($curLabel !== '' ? $curLabel : '?')) ?>">
|
||||
<?= e(mb_substr($curLabel !== '' ? $curLabel : '?', 0, 1, 'UTF-8')) ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="nav-icon-ctl">
|
||||
<input type="file" name="icon" id="navSiteIcon"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml,image/x-icon,.ico"
|
||||
class="nav-icon-file">
|
||||
<label class="btn btn-sm btn-ghost nav-icon-btn" for="navSiteIcon">选择图片…</label>
|
||||
<span class="small muted" id="navIconFname">未选择</span>
|
||||
<?php if ($editSite && $curIcon !== ''): ?>
|
||||
<label class="chk" style="margin-left:4px">
|
||||
<input type="checkbox" name="icon_clear" value="1" id="navIconClear">
|
||||
清除自定义图标
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<span class="tip">
|
||||
不选就沿用抓取到的图标(或首字母块)。上传后<b>「全部更新」不会覆盖</b>它。
|
||||
支持 png / jpg / webp / gif / ico / svg,最大 1MB。
|
||||
</span>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label class="chk">
|
||||
<input type="checkbox" name="is_visible" value="1" checked> 在展示页上显示
|
||||
@@ -852,5 +898,51 @@ $setItems = [
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* 🔴 工单 #16:自定义图标 —— 选完立刻本地预览(表单还没提交)。
|
||||
⚠️ 用 URL.createObjectURL 而不是 FileReader.readAsDataURL:
|
||||
前者不把整张图读成 base64 字符串,大图也不卡;用完要 revoke 防泄漏。
|
||||
⚠️ 预览失败(比如选了损坏的图)不要把旧预览清掉 ——
|
||||
那样用户会以为"什么都没选上",而实际文件已经挂上了。 */
|
||||
(function () {
|
||||
var inp = document.getElementById('navSiteIcon');
|
||||
var prev = document.getElementById('navIconPrev');
|
||||
var fname = document.getElementById('navIconFname');
|
||||
var clear = document.getElementById('navIconClear');
|
||||
if (!inp || !prev) { return; }
|
||||
var lastUrl = '';
|
||||
|
||||
inp.addEventListener('change', function () {
|
||||
var f = inp.files && inp.files[0];
|
||||
if (fname) { fname.textContent = f ? f.name : '未选择'; }
|
||||
if (!f) { return; }
|
||||
if (lastUrl) { URL.revokeObjectURL(lastUrl); }
|
||||
lastUrl = URL.createObjectURL(f);
|
||||
var im = new Image();
|
||||
im.alt = '';
|
||||
im.onload = function () {
|
||||
// 成功才换预览:先清空旧内容(可能是字母块或旧图)
|
||||
prev.innerHTML = '';
|
||||
prev.appendChild(im);
|
||||
// 选了新图 → 自动取消"清除"勾选(两者互斥,否则用户会以为要清掉)
|
||||
if (clear) { clear.checked = false; }
|
||||
};
|
||||
im.onerror = function () {
|
||||
if (fname) { fname.textContent = f.name + '(无法预览,仍会尝试上传)'; }
|
||||
};
|
||||
im.src = lastUrl;
|
||||
});
|
||||
|
||||
// 勾了"清除" → 把已选的文件清掉,避免两个意图打架
|
||||
if (clear) {
|
||||
clear.addEventListener('change', function () {
|
||||
if (clear.checked) {
|
||||
inp.value = '';
|
||||
if (fname) { fname.textContent = '未选择'; }
|
||||
if (lastUrl) { URL.revokeObjectURL(lastUrl); lastUrl = ''; }
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user