diff --git a/.gitignore b/.gitignore
index 58ebe76..c7a8a9b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -166,3 +166,6 @@ dedup_nav.py
scripts/_verify_*.png
scripts/screenshots/
output/
+
+# 前台卡片图标本地缓存(首次拉取后自动生成,无需入库)
+data/icon/
diff --git a/applications/models/admin_nav.py b/applications/models/admin_nav.py
index 7765a04..a6a5505 100644
--- a/applications/models/admin_nav.py
+++ b/applications/models/admin_nav.py
@@ -54,6 +54,38 @@ class Nav(db.Model):
def to_dict(self):
"""方便模板直接读取"""
+ url = self.url or ''
+ # 提取域名 / 协议,用于加载 favicon 与生成确定性配色
+ domain = ''
+ scheme = 'https'
+ try:
+ from urllib.parse import urlparse
+ parsed = urlparse(url if '://' in url else 'http://' + url)
+ domain = parsed.netloc or parsed.path.split('/')[0]
+ if parsed.scheme:
+ scheme = parsed.scheme
+ except Exception:
+ domain = ''
+
+ # 取标题首个可见字符作为「无 favicon 时的兜底字母头像」
+ letter = ''
+ for ch in (self.title or ''):
+ if ch.strip():
+ letter = ch.upper()
+ break
+ if not letter:
+ letter = (domain[:1] or '·').upper()
+
+ # 按域名哈希得到稳定的渐变配色(同域名永远同色,跨域名尽量区分)
+ hue = 0
+ if domain:
+ s = 0
+ for ch in domain:
+ s = (s * 31 + ord(ch)) & 0xffffffff
+ hue = s % 360
+ color = 'linear-gradient(135deg, hsl(%d,62%%,56%%) 0%%, hsl(%d,72%%,46%%) 100%%)' % (
+ hue, (hue + 28) % 360)
+
return {
'id': self.id,
'category': self.category,
@@ -61,6 +93,10 @@ class Nav(db.Model):
'url': self.url,
'description': self.description or '',
'icon': self.icon or 'layui-icon-link',
+ 'letter': letter,
+ 'color': color,
+ 'domain': domain,
+ 'scheme': scheme,
'sort': self.sort or 0,
'enable': self.enable,
'is_external': self.is_external,
diff --git a/applications/view/public/nav.py b/applications/view/public/nav.py
index d6691a2..dfa6df7 100644
--- a/applications/view/public/nav.py
+++ b/applications/view/public/nav.py
@@ -12,8 +12,13 @@ URL 路径(挂在根路径下,便于匿名访问):
"""
from collections import OrderedDict
import time
+import os
+import re
+import threading
+import urllib.request
+import urllib.error
-from flask import Blueprint, render_template, request, jsonify
+from flask import Blueprint, render_template, request, jsonify, current_app, send_file
from applications.models import Nav, NavCategory
from plugins.siteStats.view.stat import get_nav_visit_counts, my_top_navs
@@ -227,4 +232,169 @@ def _grouped_navs() -> "OrderedDict[str, list]":
def _category_meta() -> "dict[str, dict]":
"""返回分类元信息 {name: {icon, description, ...}},用于抽屉图标等"""
cats = NavCategory.query.filter(NavCategory.enable == 1).all()
- return {c.name: {'icon': c.icon or 'layui-icon-list', 'description': c.description or ''} for c in cats}
\ No newline at end of file
+ return {c.name: {'icon': c.icon or 'layui-icon-list', 'description': c.description or ''} for c in cats}
+
+
+# ---------------------------------------------------------------------------
+# 卡片图标本地缓存
+# 首次请求某域名图标时,自动拉取并保存到 data/icon/,以后直接读本地文件,
+# 不再重复拉取。拉取失败(含内网/不可达网址)时自动生成「站点名称首字母」SVG 兜底。
+# ---------------------------------------------------------------------------
+_ICON_DIR = None
+_ICON_LOCKS = {}
+_ICON_LOCKS_GUARD = threading.Lock()
+_ICON_EXT_MIME = {
+ '.ico': 'image/x-icon',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.svg': 'image/svg+xml',
+}
+
+
+def _icon_dir():
+ global _ICON_DIR
+ if _ICON_DIR is None:
+ base = current_app.config.get('DATA_DIR')
+ if not base:
+ base = os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'data')
+ _ICON_DIR = os.path.join(base, 'icon')
+ os.makedirs(_ICON_DIR, exist_ok=True)
+ return _ICON_DIR
+
+
+def _safe_name(domain: str) -> str:
+ return re.sub(r'[^A-Za-z0-9.\-_]', '_', domain or 'unknown')
+
+
+def _lock_for(key: str):
+ with _ICON_LOCKS_GUARD:
+ lock = _ICON_LOCKS.get(key)
+ if lock is None:
+ lock = threading.Lock()
+ _ICON_LOCKS[key] = lock
+ return lock
+
+
+def _domain_hue(domain: str) -> int:
+ """与 models.admin_nav.Nav.to_dict 中用于生成卡片字母头像的配色保持一致"""
+ s = 0
+ for ch in (domain or ''):
+ s = (s * 31 + ord(ch)) & 0xffffffff
+ return s % 360
+
+
+def _letter_svg(letter: str, domain: str) -> bytes:
+ hue = _domain_hue(domain)
+ c1 = "hsl(%d,62%%,56%%)" % hue
+ c2 = "hsl(%d,72%%,46%%)" % ((hue + 28) % 360)
+ ch = (letter or (domain or '·')[:1] or '·').upper()[:1] or '·'
+ ch = re.sub(r'[<>&"]', '', ch) # 防止 SVG 注入
+ svg = (
+ ''
+ ) % (c1, c2, ch)
+ return svg.encode('utf-8')
+
+
+def _fetch_bytes(url: str, timeout: int = 5):
+ try:
+ req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ data = resp.read()
+ ctype = resp.headers.get('Content-Type', '') or ''
+ return data, ctype
+ except Exception:
+ return None
+
+
+def _ext_from(ctype: str, data: bytes) -> str:
+ ct = (ctype or '').lower()
+ if 'png' in ct:
+ return '.png'
+ if 'jpeg' in ct or 'jpg' in ct:
+ return '.jpg'
+ if 'gif' in ct:
+ return '.gif'
+ if 'svg' in ct:
+ return '.svg'
+ if 'icon' in ct:
+ return '.ico'
+ if data[:4] == b'\x00\x00\x01\x00':
+ return '.ico'
+ if data[:8] == b'\x89PNG\r\n\x1a\n':
+ return '.png'
+ if data[:3] == b'GIF':
+ return '.gif'
+ return '.ico'
+
+
+def _resolve_icon(domain: str, letter: str):
+ """返回 (file_path, mimetype)。优先读本地缓存,否则拉取/生成并落盘。"""
+ safe = _safe_name(domain)
+ d = _icon_dir()
+ # 1) 命中本地缓存(任意已知扩展名)
+ for ext in ('.svg', '.ico', '.png', '.jpg', '.jpeg', '.gif'):
+ p = os.path.join(d, safe + ext)
+ if os.path.isfile(p):
+ return p, _ICON_EXT_MIME.get(ext, 'application/octet-stream')
+ # 2) 加锁后再次检查(避免并发重复拉取)
+ with _lock_for(safe):
+ for ext in ('.svg', '.ico', '.png', '.jpg', '.jpeg', '.gif'):
+ p = os.path.join(d, safe + ext)
+ if os.path.isfile(p):
+ return p, _ICON_EXT_MIME.get(ext, 'application/octet-stream')
+ # 3) 循序尝试:站点自身 https → http(含内网)→ DuckDuckGo
+ candidates = [
+ 'https://%s/favicon.ico' % domain,
+ 'http://%s/favicon.ico' % domain,
+ 'https://icons.duckduckgo.com/ip3/%s.ico' % domain,
+ ]
+ for url in candidates:
+ res = _fetch_bytes(url)
+ if not res:
+ continue
+ data, ctype = res
+ if not data or len(data) < 32: # 过小多半是错误页/空响应
+ continue
+ # 必须是真正的图片(按 Content-Type 或二进制魔数判断),
+ # 否则跳过——避免把 200 的 HTML 错误页当图标缓存下来
+ ct = (ctype or '').lower()
+ is_image = (
+ ct.startswith('image/')
+ or data[:4] == b'\x00\x00\x01\x00'
+ or data[:8] == b'\x89PNG\r\n\x1a\n'
+ or data[:3] == b'GIF'
+ )
+ if not is_image:
+ continue
+ ext = _ext_from(ctype, data)
+ p = os.path.join(d, safe + ext)
+ try:
+ with open(p, 'wb') as f:
+ f.write(data)
+ return p, _ICON_EXT_MIME.get(ext, 'application/octet-stream')
+ except OSError:
+ continue
+ # 4) 全部失败:生成字母头像 SVG 兜底并缓存
+ p = os.path.join(d, safe + '.svg')
+ with open(p, 'wb') as f:
+ f.write(_letter_svg(letter, domain))
+ return p, 'image/svg+xml'
+
+
+@bp.get('/icon/')
+def icon(domain):
+ """卡片图标:本地缓存优先,拉取失败时返回字母头像 SVG。"""
+ letter = request.args.get('letter', '') or ''
+ path, mime = _resolve_icon(domain, letter)
+ return send_file(path, mimetype=mime, max_age=86400)
\ No newline at end of file
diff --git a/templates/public/base.html b/templates/public/base.html
index c191c69..846dafe 100644
--- a/templates/public/base.html
+++ b/templates/public/base.html
@@ -241,10 +241,10 @@
.theme-picker-trigger { position: relative; }
.theme-menu {
display: none;
- position: absolute;
- top: calc(100% + 6px);
+ position: fixed;
+ top: 0;
left: 0;
- min-width: 150px;
+ min-width: 160px;
margin: 0; padding: 4px;
list-style: none;
background: var(--card-bg);
@@ -601,13 +601,28 @@
.cat-drawer-list a.cat-drawer-mine { color: #8b5cf6; font-weight: 600; }
.card .icon-wrap {
+ position: relative;
width: 42px; height: 42px;
- background: rgba(22, 186, 170, 0.1);
- color: var(--brand);
border-radius: 10px;
- display: flex; align-items: center; justify-content: center;
- font-size: 20px;
+ overflow: hidden;
flex-shrink: 0;
+ box-shadow: inset 0 0 0 1px rgba(0,0,0,0.05);
+ background: var(--card-bg);
+ }
+ .card .nav-letter {
+ position: absolute; inset: 0;
+ display: flex; align-items: center; justify-content: center;
+ color: #fff; font-weight: 700; font-size: 18px;
+ text-transform: uppercase;
+ user-select: none;
+ letter-spacing: 0.5px;
+ }
+ .card .nav-favicon {
+ position: absolute; inset: 0;
+ width: 100%; height: 100%;
+ object-fit: contain;
+ background: #fff;
+ z-index: 1;
}
.card-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.card .title-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
@@ -845,7 +860,8 @@
gap: 9px;
align-items: flex-start;
}
- .card .icon-wrap { width: 32px; height: 32px; font-size: 16px; border-radius: 8px; flex-shrink: 0; }
+ .card .icon-wrap { width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0; }
+ .card .nav-letter { font-size: 14px; }
.card-body { gap: 2px; min-width: 0; }
.card .title { font-size: 13px; line-height: 1.3; }
.card .desc {
@@ -1131,7 +1147,7 @@
// ===== 主题切换:多主题选择 + localStorage 持久化 + 跟随系统 =====
(function(){
var THEME_KEY = 'bw-theme';
- var FIXED_THEMES = ['default', 'sunset', 'forest', 'dark'];
+ var FIXED_THEMES = ['default', 'sunset', 'forest', 'dark', 'lavender', 'ocean', 'sky', 'rose'];
var ALL = FIXED_THEMES.concat(['auto']);
function isSystemDark(){
@@ -1141,7 +1157,7 @@
// 把"auto"解析为实际生效的主题
function resolveTheme(name){
if (name === 'auto') return isSystemDark() ? 'dark' : 'default';
- return FIXED_THEMES.indexOf(name) !== -1 ? name : 'default';
+ return ALL.indexOf(name) !== -1 ? name : 'default';
}
// 应用主题(persist=true 表示记住用户选择)
@@ -1176,25 +1192,7 @@
applyTheme('default', false);
}
- // 顶部按钮:展开/收起菜单
- var btn = document.getElementById('theme-toggle');
- var menu = document.getElementById('theme-menu');
- if (btn && menu) {
- btn.addEventListener('click', function(e){
- e.stopPropagation();
- menu.classList.toggle('open');
- });
- menu.querySelectorAll('li').forEach(function(li){
- li.addEventListener('click', function(e){
- e.stopPropagation();
- applyTheme(this.getAttribute('data-theme'), true);
- menu.classList.remove('open');
- });
- });
- document.addEventListener('click', function(){
- menu.classList.remove('open');
- });
- }
+ // 注:主题切换入口在侧栏(#side-theme-toggle)与抽屉(#drawer-theme-toggle),下方单独绑定
// 跟随系统:监听系统暗色偏好变化,自动重应用
if (window.matchMedia) {
@@ -1206,7 +1204,6 @@
if (mq.addEventListener) mq.addEventListener('change', listener);
else if (mq.addListener) mq.addListener(listener); // 旧 Safari 兼容
}
- })();
// 回到顶部
var top = document.getElementById('to-top');
if (top) {
@@ -1346,6 +1343,28 @@
}
})();
+ // 卡片图标增强:统一走本地缓存接口 /site/icon/。
+ // 服务端负责拉取并落盘到 data/icon/,拉取失败的自动返回「站点名称首字母」SVG,
+ // 因此既有真实 favicon、也不会再出现空白图标,且后续不再重复拉取。
+ window.enhanceFavicons = function(root){
+ var scope = root || document;
+ var imgs = scope.querySelectorAll('img.nav-favicon');
+ for (var i = 0; i < imgs.length; i++){
+ var img = imgs[i];
+ if (img.getAttribute('data-fav-wired')) continue;
+ img.setAttribute('data-fav-wired', '1');
+ var domain = img.getAttribute('data-domain');
+ if (!domain) continue;
+ var letter = img.getAttribute('data-letter') || '';
+ var url = '/site/icon/' + encodeURIComponent(domain);
+ if (letter) url += '?letter=' + encodeURIComponent(letter);
+ // 接口始终返回图片:成功为 favicon,失败为字母头像 SVG
+ img.onerror = function(){ img.style.display = 'none'; };
+ img.src = url;
+ img.style.display = 'block';
+ }
+ };
+
// ===== 「我常访问」:localStorage 本地累计 + 服务端匿名榜合并 =====
(function(){
var KEY = 'wk_mine_clicks';
@@ -1510,6 +1529,8 @@
} else {
render();
}
+ // 首屏渲染后,为所有卡片加载 favicon(失败的自动回退到字母头像)
+ if (window.enhanceFavicons) window.enhanceFavicons(document);
})();
// ===== 全局搜索:前端实时过滤卡片,并高亮分类 Tab =====
@@ -1596,6 +1617,23 @@
filter(q || '');
})();
+ // 将主题菜单定位到触发元素右侧(fixed 浮层,避免被侧栏 overflow 裁剪)
+ function positionThemeMenu(trigger, menu){
+ var rect = trigger.getBoundingClientRect();
+ var prevDisplay = menu.style.display;
+ menu.style.visibility = 'hidden';
+ menu.style.display = 'block';
+ var mw = menu.offsetWidth, mh = menu.offsetHeight;
+ menu.style.display = prevDisplay;
+ menu.style.visibility = '';
+ var left = rect.right + 8;
+ if (left + mw > window.innerWidth - 8) left = rect.left - mw - 8;
+ var top = rect.top;
+ if (top + mh > window.innerHeight - 8) top = Math.max(8, window.innerHeight - mh - 8);
+ menu.style.top = top + 'px';
+ menu.style.left = left + 'px';
+ }
+
// ===== 侧栏「切换主题」:弹出下拉菜单 =====
(function(){
var btn = document.getElementById('side-theme-toggle');
@@ -1609,7 +1647,7 @@
e.stopPropagation();
var wasOpen = menu.classList.contains('open');
closeAll();
- if (!wasOpen) menu.classList.add('open');
+ if (!wasOpen) { positionThemeMenu(btn, menu); menu.classList.add('open'); }
});
menu.addEventListener('click', function(e){
var li = e.target.closest('li[data-theme]');
@@ -1635,7 +1673,7 @@
e.stopPropagation();
var wasOpen = menu.classList.contains('open');
closeAll();
- if (!wasOpen) menu.classList.add('open');
+ if (!wasOpen) { positionThemeMenu(btn, menu); menu.classList.add('open'); }
});
menu.addEventListener('click', function(e){
var li = e.target.closest('li[data-theme]');
@@ -1650,6 +1688,7 @@
document.addEventListener('click', closeAll);
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeAll(); });
})();
+ })();