导航站点:新增/编辑表单缺自定义图标上传 #16

Closed
opened 2026-10-28 03:54:16 +00:00 by laoyang · 2 comments
Owner

现象(用户反馈)

「网页版,导航,增加导航地址的时候,怎么没有自定义图标功能啊,修改也没有。」

「我的网址」里新增站点、编辑已有站点两个表单,都只有四个可填项:

网址 · 所属板块 · 显示名 · 简称

没有任何可以指定图标的地方。图标只能靠两种被动方式得到:

  1. 勾选「顺便抓取网站名与图标」→ 服务端联网抓 favicon(NavFetch::fetchIcon);
  2. 新建时如果别人加过同一个 URL → 从「可复用」列表里挑一条,连它的图标一起抄过来。

一旦抓错了(抓到的是默认图标、或者是站点小 logo 糊成一团),或者站点根本没有 favicon,
用户没有任何办法换掉它 —— 列表里就一直是一个灰字母块。

现状:底层其实都齐了,只差 UI

环节 状态
数据库字段 nav_sites.icon / icon_custom ✅ 已有
图标落盘目录 Nav::ICON_DIR = uploads/nav-icons ✅ 已有
免登录公开出口 nav_icon.php?f=<文件名> ✅ 已有(严格白名单 32位hex.扩展名)
iconUrl() / deleteIconFile() / copyIconFile() ✅ 已有
「全部更新」尊重 icon_custom = 1 不覆盖 ✅ 已有(沿用 SimpleNav 语义)
用户上传入口(表单) ❌ 缺

也就是说这是纯 UI 缺口,不是要新造一套图标机制。

期望

新增站点时

在 网址 / 所属板块 / 显示名 / 简称 之后,加一项自定义图标:

  • 可以选一张本地图片上传(png / jpg / webp / ico / svg);
  • 上传后就地预览,能看出选的是哪张;
  • 可以「清除」,回到"不指定、按抓取或字母块"的状态;
  • 不传也能正常保存(这一项非必填)。

编辑已有站点时

同样能上传 / 替换 / 清除自定义图标。上传后立刻生效,不需要先删了站点重建。

语义(必须与现有 icon_custom 一致)

用户上传的图标 ⇒ icon_custom = 1 ⇒ 之后点「全部更新」不会被抓取覆盖。
这是 SimpleNav 既定的语义,也是用户「设置好以后就固定了」的原话。

清除自定义图标 ⇒ icon_custom = 0 ⇒ 允许下次「全部更新」重新抓。

验收标准

  1. 新增表单里有自定义图标控件,能上传并成功保存,列表里立刻显示新图标。
  2. 编辑表单里同样有,能替换已有图标;替换后列表与展示页都变。
  3. 上传的图标 icon_custom = 1,点「全部更新」之后仍然是用户那张(不被覆盖)。
  4. 「清除」后 icon_custom = 0,字母块 / 抓取图标恢复可用。
  5. 不传图标时行为不变:和现在完全一样(抓取 or 字母块),不能因为多了这项就让老站点变样。
  6. 安全:
    • 文件名必须落在 Nav::ICON_DIR,且符合 nav_icon.php 的白名单(32位hex.扩展名);
    • 上传类型按内容嗅探校验,不能只信扩展名;
    • SVG 要么拒绝、要么按 nav_icon.php 已有的 CSP 处理(该文件已对 SVG 加 sandbox);
    • 大小有上限,超限给明确提示,不静默失败。
  7. 不留垃圾:替换图标时旧文件要删掉(现有 deleteIconFile 已有此能力,注意别删别人复用在用的)。
  8. 移动端窄屏不溢出。
## 现象(用户反馈) > 「网页版,导航,增加导航地址的时候,怎么没有自定义图标功能啊,修改也没有。」 「我的网址」里新增站点、编辑已有站点两个表单,都只有四个可填项: `网址` · `所属板块` · `显示名` · `简称` **没有任何可以指定图标的地方**。图标只能靠两种被动方式得到: 1. 勾选「顺便抓取网站名与图标」→ 服务端联网抓 favicon(`NavFetch::fetchIcon`); 2. 新建时如果别人加过同一个 URL → 从「可复用」列表里挑一条,连它的图标一起抄过来。 一旦抓错了(抓到的是默认图标、或者是站点小 logo 糊成一团),或者站点根本没有 favicon, 用户**没有任何办法换掉它** —— 列表里就一直是一个灰字母块。 ## 现状:底层其实都齐了,只差 UI | 环节 | 状态 | |---|---| | 数据库字段 `nav_sites.icon` / `icon_custom` | ✅ 已有 | | 图标落盘目录 `Nav::ICON_DIR = uploads/nav-icons` | ✅ 已有 | | 免登录公开出口 `nav_icon.php?f=<文件名>` | ✅ 已有(严格白名单 `32位hex.扩展名`) | | `iconUrl()` / `deleteIconFile()` / `copyIconFile()` | ✅ 已有 | | 「全部更新」尊重 `icon_custom = 1` 不覆盖 | ✅ 已有(沿用 SimpleNav 语义) | | **用户上传入口(表单)** | ❌ **缺** | 也就是说这是**纯 UI 缺口**,不是要新造一套图标机制。 ## 期望 ### 新增站点时 在 `网址 / 所属板块 / 显示名 / 简称` 之后,加一项**自定义图标**: - 可以选一张本地图片上传(png / jpg / webp / ico / svg); - 上传后**就地预览**,能看出选的是哪张; - 可以「清除」,回到"不指定、按抓取或字母块"的状态; - 不传也能正常保存(这一项**非必填**)。 ### 编辑已有站点时 同样能上传 / 替换 / 清除自定义图标。**上传后立刻生效**,不需要先删了站点重建。 ### 语义(必须与现有 `icon_custom` 一致) 用户上传的图标 ⇒ `icon_custom = 1` ⇒ 之后点「全部更新」**不会被抓取覆盖**。 这是 SimpleNav 既定的语义,也是用户「设置好以后就固定了」的原话。 清除自定义图标 ⇒ `icon_custom = 0` ⇒ 允许下次「全部更新」重新抓。 ## 验收标准 1. **新增表单里有自定义图标控件**,能上传并成功保存,列表里立刻显示新图标。 2. **编辑表单里同样有**,能替换已有图标;替换后列表与展示页都变。 3. 上传的图标 `icon_custom = 1`,点「全部更新」之后**仍然是用户那张**(不被覆盖)。 4. 「清除」后 `icon_custom = 0`,字母块 / 抓取图标恢复可用。 5. **不传图标时行为不变**:和现在完全一样(抓取 or 字母块),不能因为多了这项就让老站点变样。 6. **安全**: - 文件名必须落在 `Nav::ICON_DIR`,且符合 `nav_icon.php` 的白名单(`32位hex.扩展名`); - 上传类型按**内容**嗅探校验,不能只信扩展名; - SVG 要么拒绝、要么按 `nav_icon.php` 已有的 CSP 处理(该文件已对 SVG 加 `sandbox`); - 大小有上限,超限给明确提示,不静默失败。 7. **不留垃圾**:替换图标时旧文件要删掉(现有 `deleteIconFile` 已有此能力,注意别删别人复用在用的)。 8. 移动端窄屏不溢出。
Author
Owner

做完了,已上线

ff818a3(已 push)。6 个文件。


你要的功能

导航站点的新增/编辑表单里,现在有「自定义图标」:左边 40px 预览,右边「选择图片」+ 文件名,已经设过图的还能勾「清除自定义图标」。

选完文件立刻能看到预览(本地预览,不用等上传)。上限 1MB,按文件内容判类型而不是看扩展名。

三态语义 —— 这里最容易出 bug,所以特意做死:

你的操作 结果
什么都不选,只改名字 icon 列一个字节都不碰,图不会丢
选了新图 icon = 新文件,icon_custom = 1
勾了清除 icon = '',icon_custom = 0,回落字母块
勾清除 + 又选了图 按选图算(选图时自动取消勾选)

顺带挖出两个真 bug,这才是「图标一直抓不到」的真相

1. 22 处裸函数调用缺 self::

从 SimpleNav 搬过来时,自由函数改成了 NavFetch 的静态方法,但类内部的调用点没跟着改。

PHP 报的是:

Error: Call to undefined function App\http_get()
  in app/NavFetch.php:171

整条抓取链路 —— 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 —— 一处都没跑起来过。

这既是「全部更新」按钮 500 的根因,也是图标一直抓不到的原因。

修后实测:

NavFetch::meta('https://www.baidu.com')
  → name = [百度一下,你就知道]
  → icons = 3
  → 图标落盘 3818 字节

2. ICON_STORE 常量从来没定义过

NavFetch.php 第 667 / 739 行在用,但 define('ICON_STORE', 128) 搬迁时整段漏了。PHP 8 直接 Error: Undefined constant "ICON_STORE",走到 GD 位图分支就 500。

为什么一直没暴露:saveUploaded() 一直是死代码(有定义、无调用点)。这次接上调用才炸出来。已补进 app/bootstrap.php(带 if (!defined) 保护)。


顺手加固的两处

  • saveUploaded() 原来按 md5(icon_key($url)) 命名 —— 单用户系统看不出问题,多用户会互相覆盖同一个 URL 的图。改成随机文件名。
  • 图标文件是按内容共享的 —— 同一张图可能被多条记录指着。删旧文件前先查 Nav::iconFileInUse(),别把别人还在用的删了。

验证

项 结果
php -l 5 个文件 全过
端到端 _b34_nav_icon_e2e.py 21 通过 / 0 失败
语义验收 _b34_icon_custom_semantics.py 通过

端到端覆盖:真实上传 PNG → 列表出图标 → nav_icon.php 返 200 + PNG 头 → 编辑换图 → 清除 → 只改简称图不丢 → 清理。

语义验收一开始是假绿:按钮没找到的时候它照样判「图标未变 ✅」—— 因为什么都没发生也叫"未变"。改成用「服务端回报了 N 条站点」当证据才站得住。

线上 6 个文件 md5 与仓库逐一比对,全部一致。

测试数据已清理

本轮造的 7 条测试站点(自定义图u… / 默认图u…,全指向 example.com)和 7 个专属图标文件都删了。站点数 101 → 94,最大 id 回到 125(真实数据最后一条是「学堂在线」)。

uploads/nav-icons/ 现 66 个文件 / 65 个被引用,唯一孤儿是 9 月 28 日的 ea75fea4…png,不是本轮产物,没动。

## 做完了,已上线 `ff818a3`(已 push)。6 个文件。 --- ## 你要的功能 导航站点的新增/编辑表单里,现在有「自定义图标」:左边 40px 预览,右边「选择图片」+ 文件名,已经设过图的还能勾「清除自定义图标」。 选完文件立刻能看到预览(本地预览,不用等上传)。上限 1MB,按**文件内容**判类型而不是看扩展名。 三态语义 —— 这里最容易出 bug,所以特意做死: | 你的操作 | 结果 | |---|---| | 什么都不选,只改名字 | icon 列一个字节都不碰,图不会丢 | | 选了新图 | icon = 新文件,icon_custom = 1 | | 勾了清除 | icon = '',icon_custom = 0,回落字母块 | | 勾清除 + 又选了图 | 按选图算(选图时自动取消勾选) | --- ## 顺带挖出两个真 bug,这才是「图标一直抓不到」的真相 ### 1. 22 处裸函数调用缺 `self::` 从 SimpleNav 搬过来时,自由函数改成了 `NavFetch` 的静态方法,但类内部的调用点没跟着改。 PHP 报的是: ``` Error: Call to undefined function App\http_get() in app/NavFetch.php:171 ``` 整条抓取链路 —— `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` —— **一处都没跑起来过**。 这既是「全部更新」按钮 500 的根因,也是图标一直抓不到的原因。 修后实测: ``` NavFetch::meta('https://www.baidu.com') → name = [百度一下,你就知道] → icons = 3 → 图标落盘 3818 字节 ``` ### 2. `ICON_STORE` 常量从来没定义过 `NavFetch.php` 第 667 / 739 行在用,但 `define('ICON_STORE', 128)` 搬迁时整段漏了。PHP 8 直接 `Error: Undefined constant "ICON_STORE"`,走到 GD 位图分支就 500。 **为什么一直没暴露**:`saveUploaded()` 一直是死代码(有定义、无调用点)。这次接上调用才炸出来。已补进 `app/bootstrap.php`(带 `if (!defined)` 保护)。 --- ## 顺手加固的两处 - **`saveUploaded()` 原来按 `md5(icon_key($url))` 命名** —— 单用户系统看不出问题,多用户会互相覆盖同一个 URL 的图。改成随机文件名。 - **图标文件是按内容共享的** —— 同一张图可能被多条记录指着。删旧文件前先查 `Nav::iconFileInUse()`,别把别人还在用的删了。 --- ## 验证 | 项 | 结果 | |---|---| | `php -l` 5 个文件 | 全过 | | 端到端 `_b34_nav_icon_e2e.py` | **21 通过 / 0 失败** | | 语义验收 `_b34_icon_custom_semantics.py` | 通过 | 端到端覆盖:真实上传 PNG → 列表出图标 → `nav_icon.php` 返 200 + PNG 头 → 编辑换图 → 清除 → 只改简称图不丢 → 清理。 语义验收一开始是**假绿**:按钮没找到的时候它照样判「图标未变 ✅」—— 因为什么都没发生也叫"未变"。改成用「服务端回报了 N 条站点」当证据才站得住。 线上 6 个文件 md5 与仓库逐一比对,全部一致。 ## 测试数据已清理 本轮造的 7 条测试站点(`自定义图u…` / `默认图u…`,全指向 example.com)和 7 个专属图标文件都删了。站点数 101 → **94**,最大 id 回到 **125**(真实数据最后一条是「学堂在线」)。 `uploads/nav-icons/` 现 66 个文件 / 65 个被引用,唯一孤儿是 9 月 28 日的 `ea75fea4…png`,不是本轮产物,**没动**。
Author
Owner

已实现并部署。站点表单新增自定义图标上传:40px 预览、选图即时预览、可换图可清除,三态语义保证只改名不丢图。

连带修复两个真 bug:NavFetch 里 22 处裸函数调用缺 self::(「全部更新」500 与图标抓不到的根因)、ICON_STORE 常量从未定义。整条抓取链路从全程失败恢复可用。

端到端 21/0,线上 6 文件 md5 与仓库一致,测试数据已清理。commit ff818a3。

已实现并部署。站点表单新增自定义图标上传:40px 预览、选图即时预览、可换图可清除,三态语义保证只改名不丢图。 连带修复两个真 bug:`NavFetch` 里 22 处裸函数调用缺 `self::`(「全部更新」500 与图标抓不到的根因)、`ICON_STORE` 常量从未定义。整条抓取链路从全程失败恢复可用。 端到端 21/0,线上 6 文件 md5 与仓库一致,测试数据已清理。commit `ff818a3`。
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: laoyang/FamilyBook#16