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:
2026-10-28 12:48:47 +08:00
parent eaa74bc593
commit ff818a3deb
6 changed files with 312 additions and 27 deletions
+73 -2
View File
@@ -220,6 +220,13 @@ final class Nav
*
* ⚠️ `icon_custom` 的语义照搬 SimpleNav:「全部更新」抓图标时**不动**
* 用户自己传的图标。漏掉这一条,用户精心挑的图标会被下一次批量更新冲掉。
*
* 🔴 工单 #16:新增两个可选键
* · `icon` —— 自定义图标文件名(已在 ICON_DIR 里落好盘);空串 = 不改
* · `icon_clear` —— true = 清掉自定义图标,回到"抓取 / 字母块"
* ⚠️ 两者语义是**互斥**的,同时给时以 `icon` 为准(清完又传没有意义)。
* ⚠️ 只有显式给了这两个键之一才动 icon 列 —— 否则 `site_save` 每次
* 普通保存都会把用户之前设的图标洗掉(那正是"改一次名字图标就没了")。
*/
public static function saveSite(int $personId, array $d, int $id = 0): int
{
@@ -235,23 +242,87 @@ final class Nav
$name = self::domainOf($url);
}
$now = date('Y-m-d H:i:s');
// ---- 自定义图标:三态(不动 / 设成新文件 / 清空)----
$hasIconKey = array_key_exists('icon', $d) || !empty($d['icon_clear']);
$newIcon = '';
$iconCustom = 0;
if (array_key_exists('icon', $d) && (string)$d['icon'] !== '') {
$cand = (string)$d['icon'];
// 只认纯文件名(与 iconUrl / deleteIconFile / copyIconFile 同一套判据)
if (strpos($cand, '/') === false && strpos($cand, '\\') === false
&& strpos($cand, '..') === false) {
$newIcon = $cand;
$iconCustom = 1;
}
}
if ($id > 0) {
if ($hasIconKey && $newIcon !== '') {
// 换图标:先记下旧文件名,落库之后再删(删早了万一 UPDATE 失败就丢图)
$old = (string)(self::site($personId, $id)['icon'] ?? '');
Database::get()->prepare(
'UPDATE nav_sites SET group_id = ?, url = ?, name = ?, short_name = ?,
is_visible = ?, icon = ?, icon_custom = 1, updated_at = ?
WHERE id = ? AND person_id = ?'
)->execute([$gid, $url, $name, $short, $vis, $newIcon, $now, $id, $personId]);
if ($old !== '' && $old !== $newIcon && !self::iconFileInUse($old, $id)) {
self::deleteIconFile($old);
}
return $id;
}
if ($hasIconKey && $newIcon === '') {
// 清空自定义图标:icon 也清掉,让 iconUrl() 回落到字母块 / 下次抓取
$old = (string)(self::site($personId, $id)['icon'] ?? '');
Database::get()->prepare(
'UPDATE nav_sites SET group_id = ?, url = ?, name = ?, short_name = ?,
is_visible = ?, icon = \'\', icon_custom = 0, updated_at = ?
WHERE id = ? AND person_id = ?'
)->execute([$gid, $url, $name, $short, $vis, $now, $id, $personId]);
if ($old !== '' && !self::iconFileInUse($old, $id)) {
self::deleteIconFile($old);
}
return $id;
}
// 普通的"改名字/改板块":icon 列一个字都不碰
Database::get()->prepare(
'UPDATE nav_sites SET group_id = ?, url = ?, name = ?, short_name = ?, is_visible = ?,
updated_at = ? WHERE id = ? AND person_id = ?'
)->execute([$gid, $url, $name, $short, $vis, $now, $id, $personId]);
return $id;
}
// ⚠️ 新增一律排**最后**(SimpleNav 的明确行为:不插进用户已排好的顺序里)
$sort = self::nextSort('nav_sites', $personId, 'group_id', $gid);
Database::get()->prepare(
'INSERT INTO nav_sites (person_id, group_id, url, name, short_name, icon, icon_custom,
sort_order, is_visible, created_at, updated_at)
VALUES (?,?,?,?,?,\'\',0,?,?,?,?)'
)->execute([$personId, $gid, $url, $name, $short, $sort, $vis, $now, $now]);
VALUES (?,?,?,?,?,?,?,?,?,?,?)'
)->execute([$personId, $gid, $url, $name, $short, $newIcon, $iconCustom,
$sort, $vis, $now, $now]);
return (int)Database::get()->lastInsertId();
}
/**
* 这个图标文件还有没有**别人**在用(除 $exceptSiteId 之外)。
*
* 🔴 为什么必须查:图标文件在 SimpleNav 里是"一个网址一个文件"(md5(url)),
* 家书搬过来后仍然沿用这个规则给**抓取**的图标命名 ⇒ 多用户可能指向同一个文件。
* 换图标时若无脑 unlink,会把别人的图标一起删掉(对方列表里当场变成破图)。
* ⇒ 删之前先数一下还有几条记录指向它,只剩自己那条才删。
*/
public static function iconFileInUse(string $file, int $exceptSiteId = 0): bool
{
if ($file === '') {
return false;
}
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM nav_sites WHERE icon = ? AND id <> ?'
);
$st->execute([$file, $exceptSiteId]);
return (int)$st->fetchColumn() > 0;
}
public static function removeSite(int $personId, int $id): void
{
$s = self::site($personId, $id);
+35 -22
View File
@@ -84,10 +84,23 @@ final class NavFetch
*
* ⚠️ 落盘后调用方**必须**把 `nav_sites.icon_custom` 置 1 ——
* 否则下一次「全部更新」会把用户精心挑的图标冲掉(SimpleNav 原文明确要求)。
*
* 🔴 工单 #16:这里**不能**再用 `icon_key($url)`(= md5(网址))当文件名。
* 原项目 SimpleNav 是**单用户**系统,"一个网址一个图标文件"没问题;
* 家书是**多用户**的,两个人给同一个网址上传不同图标时,
* md5(url) 会让后传的**直接覆盖**先传的 —— A 在自己导航里看到 B 的图标,
* 而且 A 那条的 `icon` 字段还指向这个文件,等于两个人的图标互相串。
*
* 改用 `random_bytes` 生成**每次唯一**的文件名(与 `Nav::copyIconFile()`
* 同一套做法):上传一次一个文件,换图标就换文件名,
* 旧的由调用方显式删除(`Nav::deleteIconFile`,且只在没人复用时删)。
*/
public static function saveUploaded(string $binary, string $url): string
public static function saveUploaded(string $binary, string $url = ''): string
{
return self::save_custom_icon($binary, self::icon_key($url));
// $url 保留在签名里只为兼容旧调用;不再参与文件命名(见上面说明)。
// save_custom_icon 内部取 md5($key) 当文件名 ⇒ 传一个随机键即得唯一文件名
// (结果是 32 位十六进制,正好符合 nav_icon.php 的白名单)。
return self::save_custom_icon($binary, bin2hex(random_bytes(16)));
}
/** curl 抓取(带 UA、超时、跟随跳转) */
@@ -155,14 +168,14 @@ final class NavFetch
$url = Nav::normUrl($url);
$out = ['title' => '', 'name' => '', 'icon' => '', 'icons' => [], 'final_url' => $url];
$resp = http_get($url);
$resp = self::http_get($url);
if ($resp === null) {
$out['icons'] = array_merge(third_party_icon_urls($url), default_icon_candidates($url));
$out['icons'] = array_merge(self::third_party_icon_urls($url), self::default_icon_candidates($url));
$out['icon'] = $out['icons'][0] ?? '';
return $out;
}
$html = ensure_utf8((string)$resp['body']);
$html = self::ensure_utf8((string)$resp['body']);
$final = $resp['url'] !== '' ? $resp['url'] : $url;
$out['final_url'] = $final;
@@ -170,7 +183,7 @@ final class NavFetch
if (preg_match('#<title[^>]*>(.*?)</title>#is', $html, $m)) {
$out['title'] = trim(preg_replace('/\s+/u', ' ', html_entity_decode($m[1], ENT_QUOTES, 'UTF-8')));
}
$out['name'] = meta_content($html, 'og:site_name');
$out['name'] = self::meta_content($html, 'og:site_name');
if ($out['name'] === '' && $out['title'] !== '') {
// 用标题的首段(按分隔符切分)作为名称
$seg = preg_split('#[\|\-_—·::]#u', $out['title']);
@@ -183,20 +196,20 @@ final class NavFetch
// 3. <link rel="icon">,按 sizes 降序(SVG 视为最高清)
// 4. 联网搜索:第三方图标聚合服务(仅当上面都没拿到高清图时才会用到)
// 5. 兜底:站点自身 /favicon.ico
[$links, $manifestUrl] = parse_icon_links($html, $final);
[$links, $manifestUrl] = self::parse_icon_links($html, $final);
$icons = [];
if ($manifestUrl !== '') {
foreach (manifest_icons($manifestUrl) as $u) {
foreach (self::manifest_icons($manifestUrl) as $u) {
$icons[] = $u;
}
}
foreach ($links as $u) {
$icons[] = $u;
}
foreach (third_party_icon_urls($final) as $u) {
foreach (self::third_party_icon_urls($final) as $u) {
$icons[] = $u;
}
foreach (default_icon_candidates($final) as $u) {
foreach (self::default_icon_candidates($final) as $u) {
$icons[] = $u;
}
@@ -274,12 +287,12 @@ final class NavFetch
if (preg_match_all('#<link\b[^>]*>#i', $html, $tags)) {
foreach ($tags[0] as $tag) {
$relv = attr_value($tag, 'rel');
$relv = self::attr_value($tag, 'rel');
if ($relv === '') {
continue;
}
$relv = strtolower($relv);
$href = html_entity_decode(attr_value($tag, 'href'), ENT_QUOTES, 'UTF-8');
$href = html_entity_decode(self::attr_value($tag, 'href'), ENT_QUOTES, 'UTF-8');
if ($href === '') {
continue;
}
@@ -295,7 +308,7 @@ final class NavFetch
continue;
}
$size = 0;
$raw = attr_value($tag, 'sizes');
$raw = self::attr_value($tag, 'sizes');
if ($raw !== '') {
foreach (preg_split('/[\s,]+/', trim($raw)) ?: [] as $one) {
if (stripos($one, 'any') !== false) {
@@ -306,7 +319,7 @@ final class NavFetch
}
}
// SVG 是矢量,视为最高清
if (preg_match('#\.svg(\?|$)#i', $href) || stripos(attr_value($tag, 'type'), 'svg') !== false) {
if (preg_match('#\.svg(\?|$)#i', $href) || stripos(self::attr_value($tag, 'type'), 'svg') !== false) {
$size = max($size, 512);
}
$rel[] = ['size' => $size, 'url' => $href];
@@ -316,9 +329,9 @@ final class NavFetch
usort($rel, static function ($a, $b) { return $b['size'] <=> $a['size']; });
$list = [];
foreach ($apple as $h) { $list[] = absolutize($h, $base); }
foreach ($rel as $r) { $list[] = absolutize($r['url'], $base); }
return [$list, $manifest === '' ? '' : absolutize($manifest, $base)];
foreach ($apple as $h) { $list[] = self::absolutize($h, $base); }
foreach ($rel as $r) { $list[] = self::absolutize($r['url'], $base); }
return [$list, $manifest === '' ? '' : self::absolutize($manifest, $base)];
}
/** 读取 web app manifest,返回其中图标(按尺寸降序) */
@@ -326,7 +339,7 @@ final class NavFetch
/** 读取 web app manifest,返回其中图标(按尺寸降序) */
private static function manifest_icons(string $manifestUrl): array
{
$r = http_get($manifestUrl, 200000, 6);
$r = self::http_get($manifestUrl, 200000, 6);
if ($r === null) {
return [];
}
@@ -350,7 +363,7 @@ final class NavFetch
}
}
}
$list[] = ['size' => $size, 'url' => absolutize($ic['src'], $base)];
$list[] = ['size' => $size, 'url' => self::absolutize($ic['src'], $base)];
}
usort($list, static function ($a, $b) { return $b['size'] <=> $a['size']; });
return array_values(array_unique(array_column($list, 'url')));
@@ -488,13 +501,13 @@ final class NavFetch
break;
}
$tried++;
$r = grab_icon($u);
$r = self::grab_icon($u);
if ($r === null) {
continue;
}
// 第三方聚合服务在「查不到图标」时会返回一块浅灰底 + 单个深灰字母的生成图,
// 那还不如本地首字母块好看 —— 识别出来就丢弃,继续回退到站点自己的图标。
if ($r['ext'] === 'png' && is_third_party_icon($u) && looks_generated_tile($r['tmp'])) {
if ($r['ext'] === 'png' && self::is_third_party_icon($u) && self::looks_generated_tile($r['tmp'])) {
@unlink($r['tmp']);
continue;
}
@@ -601,7 +614,7 @@ final class NavFetch
*/
private static function grab_icon(string $iconUrl): ?array
{
$resp = http_get($iconUrl, 2 * 1024 * 1024, 5);
$resp = self::http_get($iconUrl, 2 * 1024 * 1024, 5);
if ($resp === null) {
return null;
}
+20
View File
@@ -23,6 +23,26 @@ define('FB_ROOT', dirname(__DIR__));
define('FB_APP', __DIR__);
define('FB_UPLOADS', FB_ROOT . '/uploads');
/**
* 导航图标统一落盘的边长(正方形,只缩不放、透明留白居中)。
*
* 🔴 工单 #16:这个常量从 SimpleNav 搬迁时**漏搬了定义**。
* `NavFetch::grab_icon()` 与 `NavFetch::save_custom_icon()` 两处都在用它
* (`imagecreatetruecolor($store, $store)`),但全仓没有 `define`。
* PHP 8 下访问未定义常量抛 `Error: Undefined constant "ICON_STORE"`
* ⇒ 走到 GD 位图分支就 500。
*
* 为什么之前一直没暴露:`saveUploaded()` 是**死代码**(只有定义、没有调用点,
* 因为表单里没有上传入口 = 工单 #16 本身);而抓取路径里 SVG 分支在前、
* 很多站点 favicon 恰好是 SVG,绕过了 GD。只要有位图 favicon 就会崩。
*
* 取值沿用 SimpleNav 原文:128 —— 高清屏下按 45~64px 显示仍然锐利,
* 同时 128×128 的 PNG 只有几 KB,20 个格子也不心疼。
*/
if (!defined('ICON_STORE')) {
define('ICON_STORE', 128);
}
// ---------------- 配置 ----------------
$configFile = FB_ROOT . '/config.php';
if (!is_file($configFile)) {
+25
View File
@@ -4982,6 +4982,31 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
display: inline-flex; align-items: center; justify-content: center;
color: #fff; font-size: 11px; border: 0;
}
/* 大号字母块:图标选择器里的预览位(与列表里的 20px 区分开) */
.nav-ico-lg { width: 40px; height: 40px; font-size: 18px; border-radius: 8px; }
/* ------------------------------------------------------------
站点表单:自定义图标选择器(工单 #16)
左侧预览、右侧文件选择 + 说明。预览用 40px 见方,
和列表里的 20px 小图标区分开,让用户能看清传的是什么。
------------------------------------------------------------ */
.nav-icon-pick { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.nav-icon-prev {
flex: 0 0 auto; width: 44px; height: 44px;
display: flex; align-items: center; justify-content: center;
border: 1px solid var(--line-2); border-radius: 10px; background: #fff;
overflow: hidden;
}
.nav-icon-prev img { width: 40px; height: 40px; object-fit: contain; display: block; }
.nav-icon-ctl { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }
/* 原生 file 控件隐藏,用 <label for> 当按钮 —— 各浏览器原生按钮长相差异太大 */
.nav-icon-file { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.nav-icon-btn { cursor: pointer; margin: 0; }
/* 焦点可达性:file 输入被键盘聚焦时,让标签有个可见的焦点环 */
.nav-icon-file:focus-visible + .nav-icon-btn {
outline: 2px solid var(--brand); outline-offset: 2px;
}
#navIconFname { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-sname { flex: 0 0 auto; font-size: 13.5px; font-weight: 600; }
.nav-surl { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-acts { margin-left: auto; display: inline-flex; gap: 6px; flex-wrap: wrap; }
+66 -2
View File
@@ -7601,14 +7601,78 @@ $want = array_keys($want);
case 'site_save':
$url = Nav::normUrl((string)($_POST['url'] ?? ''));
$id = Nav::saveSite($me, [
/* 🔴 工单 #16:自定义图标上传(用户 2026-10-28「增加导航地址的时候,
怎么没有自定义图标功能啊,修改也没有」)。
放在 saveSite **之前**:先把文件落盘拿到文件名,再连同其它字段一起存,
这样"新增站点 + 上传图标"一次提交就完成,不用先建再改。
⚠️ 上传失败(类型不对 / 太大 / 不是图)时**不能**静默继续保存 ——
否则用户以为图标传上去了,列表里却还是老样子。
但也不能整条拒绝:他填的网址/名字是有效的。
⇒ 策略:图标失败就报错退出,让他重传(比"保存了但图标没了"好懂)。
⚠️ 只在**真的选了文件**时才处理:`UPLOAD_ERR_NO_FILE` 表示这次没打算换图标,
必须当成"不改",不能当成"清空"(那就是"改个名字图标就没了")。 */
$iconFile = '';
$iconClear = !empty($_POST['icon_clear']);
$up = $_FILES['icon'] ?? null;
if (is_array($up) && (int)($up['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
if ((int)$up['error'] !== UPLOAD_ERR_OK) {
$bad('图标上传失败(服务器限制 ' . ini_get('upload_max_filesize')
. '),换一张小一点的图试试。');
}
$tmp = (string)($up['tmp_name'] ?? '');
if ($tmp === '' || !is_uploaded_file($tmp)) {
$bad('图标没传上来,请重试。');
}
// 体积上限 1MB:图标不需要更大(128×128 输出,几 KB 就够)
if ((int)($up['size'] ?? 0) > 1024 * 1024) {
$bad('图标太大了(上限 1MB),换一张小一点的。');
}
$bin = (string)@file_get_contents($tmp);
if ($bin === '') {
$bad('图标读不出来,请重试。');
}
// 🔴 按**内容**判类型,绝不信扩展名(与 media.php / nav_icon.php 同一口径)
$mime = '';
if (function_exists('finfo_open')) {
$fi = finfo_open(FILEINFO_MIME_TYPE);
$mime = (string)finfo_buffer($fi, $bin);
finfo_close($fi);
}
$head = ltrim(substr($bin, 0, 1024));
$isSvg = stripos($mime, 'svg') !== false || stripos($head, '<svg') !== false;
if (!$isSvg && strpos($mime, 'image/') !== 0) {
$bad('这不是图片文件(识别为 ' . ($mime !== '' ? $mime : '未知') . ')。');
}
$iconFile = NavFetch::saveUploaded($bin, $url);
if ($iconFile === '') {
$bad('图标存不下来(格式不支持,或 SVG 里带了脚本)。');
}
}
$siteData = [
'group_id' => (int)($_POST['group_id'] ?? 0),
'url' => $url,
'name' => (string)($_POST['name'] ?? ''),
'short_name' => (string)($_POST['short_name'] ?? ''),
'is_visible' => !empty($_POST['is_visible']),
], $gid);
];
/* 三态:传了新图 → 设;勾了清除 → 清;都没做 → 一个字不碰。
⚠️ 只有真给了键才传,`saveSite` 靠 `array_key_exists` 判断"要不要动 icon",
随手传一个空串会变成"清空"(那正是工单 #8 那类"改个名字图标没了")。 */
if ($iconFile !== '') {
$siteData['icon'] = $iconFile;
} elseif ($iconClear) {
$siteData['icon_clear'] = true;
}
$id = Nav::saveSite($me, $siteData, $gid);
if ($id <= 0) {
// 站点没存成 → 刚落盘的图标成了孤儿,删掉(连同它一起白费)
if ($iconFile !== '') {
Nav::deleteIconFile($iconFile);
}
$bad('站点地址和所属板块都不能空。');
}
/* 🔴 v24 第 26 批 用户第 4 条:**复用别人加过的站点**。
+93 -1
View File
@@ -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>