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 = ( + '' + '' + '' + '' + '' + '' + '%s' + '' + ) % (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(); }); })(); + })(); \ No newline at end of file diff --git a/templates/public/category.html b/templates/public/category.html index 864db77..95e638a 100644 --- a/templates/public/category.html +++ b/templates/public/category.html @@ -29,7 +29,12 @@ data-nav-id="{{ item.id }}" {% if item.is_external %}target="_blank" rel="noopener noreferrer"{% endif %}>
- + {{ item.letter }} + {% if item.domain %} + + {% endif %}
diff --git a/templates/public/index.html b/templates/public/index.html index c3f87e8..f8e2de5 100644 --- a/templates/public/index.html +++ b/templates/public/index.html @@ -8,7 +8,13 @@ {%- if mine is not none %} data-mine="{{ mine }}"{% endif %} {%- if item.is_external %} target="_blank" rel="noopener noreferrer"{% endif %}>
- + {{ item.letter }} + {% if item.domain %} + {# 图标走本地缓存接口 /site/icon/,由服务端拉取并落盘到 data/icon/,失败自动生成字母头像 #} + + {% endif %}