feat(public): 主题切换修复 + 卡片图标服务端本地缓存
- 主题切换:菜单改为 fixed 定位并弹出在父菜单右侧;FIXED_THEMES 扩为 9 主题并统一校验; 子菜单点击无响应修复(合并 IIFE 作用域使 applyTheme 对点击处理器可见);删除死代码。 - 卡片图标:新增 GET /site/icon/<domain>?letter=X 接口,首次请求由服务端拉取 favicon 并缓存到 data/icon/(运行时自动建目录,已加 .gitignore),之后直接读本地不再重复拉取; 拉取失败或内网/不可达网址自动生成「站点名称首字母」SVG 头像兜底,杜绝空白图标。 - 模型 Nav.to_dict 新增 domain/scheme/letter/color 计算字段,供卡片与图标接口复用。
This commit is contained in:
@@ -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}
|
||||
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 = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">'
|
||||
'<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">'
|
||||
'<stop offset="0%%" stop-color="%s"/>'
|
||||
'<stop offset="100%%" stop-color="%s"/>'
|
||||
'</linearGradient></defs>'
|
||||
'<rect width="64" height="64" rx="14" fill="url(#g)"/>'
|
||||
'<text x="32" y="33" text-anchor="middle" dominant-baseline="central" '
|
||||
'font-family="Arial, \'PingFang SC\', \'Microsoft YaHei\', sans-serif" '
|
||||
'font-size="34" font-weight="700" fill="#ffffff">%s</text>'
|
||||
'</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/<string:domain>')
|
||||
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)
|
||||
Reference in New Issue
Block a user