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);