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:
@@ -166,3 +166,6 @@ dedup_nav.py
|
|||||||
scripts/_verify_*.png
|
scripts/_verify_*.png
|
||||||
scripts/screenshots/
|
scripts/screenshots/
|
||||||
output/
|
output/
|
||||||
|
|
||||||
|
# 前台卡片图标本地缓存(首次拉取后自动生成,无需入库)
|
||||||
|
data/icon/
|
||||||
|
|||||||
@@ -54,6 +54,38 @@ class Nav(db.Model):
|
|||||||
|
|
||||||
def to_dict(self):
|
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 {
|
return {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'category': self.category,
|
'category': self.category,
|
||||||
@@ -61,6 +93,10 @@ class Nav(db.Model):
|
|||||||
'url': self.url,
|
'url': self.url,
|
||||||
'description': self.description or '',
|
'description': self.description or '',
|
||||||
'icon': self.icon or 'layui-icon-link',
|
'icon': self.icon or 'layui-icon-link',
|
||||||
|
'letter': letter,
|
||||||
|
'color': color,
|
||||||
|
'domain': domain,
|
||||||
|
'scheme': scheme,
|
||||||
'sort': self.sort or 0,
|
'sort': self.sort or 0,
|
||||||
'enable': self.enable,
|
'enable': self.enable,
|
||||||
'is_external': self.is_external,
|
'is_external': self.is_external,
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ URL 路径(挂在根路径下,便于匿名访问):
|
|||||||
"""
|
"""
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
import time
|
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 applications.models import Nav, NavCategory
|
||||||
from plugins.siteStats.view.stat import get_nav_visit_counts, my_top_navs
|
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]":
|
def _category_meta() -> "dict[str, dict]":
|
||||||
"""返回分类元信息 {name: {icon, description, ...}},用于抽屉图标等"""
|
"""返回分类元信息 {name: {icon, description, ...}},用于抽屉图标等"""
|
||||||
cats = NavCategory.query.filter(NavCategory.enable == 1).all()
|
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)
|
||||||
+71
-32
@@ -241,10 +241,10 @@
|
|||||||
.theme-picker-trigger { position: relative; }
|
.theme-picker-trigger { position: relative; }
|
||||||
.theme-menu {
|
.theme-menu {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: calc(100% + 6px);
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
min-width: 150px;
|
min-width: 160px;
|
||||||
margin: 0; padding: 4px;
|
margin: 0; padding: 4px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
@@ -601,13 +601,28 @@
|
|||||||
.cat-drawer-list a.cat-drawer-mine { color: #8b5cf6; font-weight: 600; }
|
.cat-drawer-list a.cat-drawer-mine { color: #8b5cf6; font-weight: 600; }
|
||||||
|
|
||||||
.card .icon-wrap {
|
.card .icon-wrap {
|
||||||
|
position: relative;
|
||||||
width: 42px; height: 42px;
|
width: 42px; height: 42px;
|
||||||
background: rgba(22, 186, 170, 0.1);
|
|
||||||
color: var(--brand);
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
overflow: hidden;
|
||||||
font-size: 20px;
|
|
||||||
flex-shrink: 0;
|
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-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; }
|
.card .title-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
@@ -845,7 +860,8 @@
|
|||||||
gap: 9px;
|
gap: 9px;
|
||||||
align-items: flex-start;
|
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-body { gap: 2px; min-width: 0; }
|
||||||
.card .title { font-size: 13px; line-height: 1.3; }
|
.card .title { font-size: 13px; line-height: 1.3; }
|
||||||
.card .desc {
|
.card .desc {
|
||||||
@@ -1131,7 +1147,7 @@
|
|||||||
// ===== 主题切换:多主题选择 + localStorage 持久化 + 跟随系统 =====
|
// ===== 主题切换:多主题选择 + localStorage 持久化 + 跟随系统 =====
|
||||||
(function(){
|
(function(){
|
||||||
var THEME_KEY = 'bw-theme';
|
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']);
|
var ALL = FIXED_THEMES.concat(['auto']);
|
||||||
|
|
||||||
function isSystemDark(){
|
function isSystemDark(){
|
||||||
@@ -1141,7 +1157,7 @@
|
|||||||
// 把"auto"解析为实际生效的主题
|
// 把"auto"解析为实际生效的主题
|
||||||
function resolveTheme(name){
|
function resolveTheme(name){
|
||||||
if (name === 'auto') return isSystemDark() ? 'dark' : 'default';
|
if (name === 'auto') return isSystemDark() ? 'dark' : 'default';
|
||||||
return FIXED_THEMES.indexOf(name) !== -1 ? name : 'default';
|
return ALL.indexOf(name) !== -1 ? name : 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用主题(persist=true 表示记住用户选择)
|
// 应用主题(persist=true 表示记住用户选择)
|
||||||
@@ -1176,25 +1192,7 @@
|
|||||||
applyTheme('default', false);
|
applyTheme('default', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 顶部按钮:展开/收起菜单
|
// 注:主题切换入口在侧栏(#side-theme-toggle)与抽屉(#drawer-theme-toggle),下方单独绑定
|
||||||
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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跟随系统:监听系统暗色偏好变化,自动重应用
|
// 跟随系统:监听系统暗色偏好变化,自动重应用
|
||||||
if (window.matchMedia) {
|
if (window.matchMedia) {
|
||||||
@@ -1206,7 +1204,6 @@
|
|||||||
if (mq.addEventListener) mq.addEventListener('change', listener);
|
if (mq.addEventListener) mq.addEventListener('change', listener);
|
||||||
else if (mq.addListener) mq.addListener(listener); // 旧 Safari 兼容
|
else if (mq.addListener) mq.addListener(listener); // 旧 Safari 兼容
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
// 回到顶部
|
// 回到顶部
|
||||||
var top = document.getElementById('to-top');
|
var top = document.getElementById('to-top');
|
||||||
if (top) {
|
if (top) {
|
||||||
@@ -1346,6 +1343,28 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// 卡片图标增强:统一走本地缓存接口 /site/icon/<domain>。
|
||||||
|
// 服务端负责拉取并落盘到 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 本地累计 + 服务端匿名榜合并 =====
|
// ===== 「我常访问」:localStorage 本地累计 + 服务端匿名榜合并 =====
|
||||||
(function(){
|
(function(){
|
||||||
var KEY = 'wk_mine_clicks';
|
var KEY = 'wk_mine_clicks';
|
||||||
@@ -1510,6 +1529,8 @@
|
|||||||
} else {
|
} else {
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
// 首屏渲染后,为所有卡片加载 favicon(失败的自动回退到字母头像)
|
||||||
|
if (window.enhanceFavicons) window.enhanceFavicons(document);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ===== 全局搜索:前端实时过滤卡片,并高亮分类 Tab =====
|
// ===== 全局搜索:前端实时过滤卡片,并高亮分类 Tab =====
|
||||||
@@ -1596,6 +1617,23 @@
|
|||||||
filter(q || '');
|
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(){
|
(function(){
|
||||||
var btn = document.getElementById('side-theme-toggle');
|
var btn = document.getElementById('side-theme-toggle');
|
||||||
@@ -1609,7 +1647,7 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
var wasOpen = menu.classList.contains('open');
|
var wasOpen = menu.classList.contains('open');
|
||||||
closeAll();
|
closeAll();
|
||||||
if (!wasOpen) menu.classList.add('open');
|
if (!wasOpen) { positionThemeMenu(btn, menu); menu.classList.add('open'); }
|
||||||
});
|
});
|
||||||
menu.addEventListener('click', function(e){
|
menu.addEventListener('click', function(e){
|
||||||
var li = e.target.closest('li[data-theme]');
|
var li = e.target.closest('li[data-theme]');
|
||||||
@@ -1635,7 +1673,7 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
var wasOpen = menu.classList.contains('open');
|
var wasOpen = menu.classList.contains('open');
|
||||||
closeAll();
|
closeAll();
|
||||||
if (!wasOpen) menu.classList.add('open');
|
if (!wasOpen) { positionThemeMenu(btn, menu); menu.classList.add('open'); }
|
||||||
});
|
});
|
||||||
menu.addEventListener('click', function(e){
|
menu.addEventListener('click', function(e){
|
||||||
var li = e.target.closest('li[data-theme]');
|
var li = e.target.closest('li[data-theme]');
|
||||||
@@ -1650,6 +1688,7 @@
|
|||||||
document.addEventListener('click', closeAll);
|
document.addEventListener('click', closeAll);
|
||||||
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeAll(); });
|
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeAll(); });
|
||||||
})();
|
})();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -29,7 +29,12 @@
|
|||||||
data-nav-id="{{ item.id }}"
|
data-nav-id="{{ item.id }}"
|
||||||
{% if item.is_external %}target="_blank" rel="noopener noreferrer"{% endif %}>
|
{% if item.is_external %}target="_blank" rel="noopener noreferrer"{% endif %}>
|
||||||
<div class="icon-wrap">
|
<div class="icon-wrap">
|
||||||
<i class="layui-icon {{ item.icon }}"></i>
|
<span class="nav-letter" style="background:{{ item.color }}">{{ item.letter }}</span>
|
||||||
|
{% if item.domain %}
|
||||||
|
<img class="nav-favicon" alt="" loading="lazy" referrerpolicy="no-referrer"
|
||||||
|
data-domain="{{ item.domain }}" data-letter="{{ item.letter }}"
|
||||||
|
style="display:none">
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="title-row">
|
<div class="title-row">
|
||||||
|
|||||||
@@ -8,7 +8,13 @@
|
|||||||
{%- if mine is not none %} data-mine="{{ mine }}"{% endif %}
|
{%- if mine is not none %} data-mine="{{ mine }}"{% endif %}
|
||||||
{%- if item.is_external %} target="_blank" rel="noopener noreferrer"{% endif %}>
|
{%- if item.is_external %} target="_blank" rel="noopener noreferrer"{% endif %}>
|
||||||
<div class="icon-wrap">
|
<div class="icon-wrap">
|
||||||
<i class="layui-icon {{ item.icon }}"></i>
|
<span class="nav-letter" style="background:{{ item.color }}">{{ item.letter }}</span>
|
||||||
|
{% if item.domain %}
|
||||||
|
{# 图标走本地缓存接口 /site/icon/<domain>,由服务端拉取并落盘到 data/icon/,失败自动生成字母头像 #}
|
||||||
|
<img class="nav-favicon" alt="" loading="lazy" referrerpolicy="no-referrer"
|
||||||
|
data-domain="{{ item.domain }}" data-letter="{{ item.letter }}"
|
||||||
|
style="display:none">
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="title-row">
|
<div class="title-row">
|
||||||
|
|||||||
Reference in New Issue
Block a user