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:
+71
-32
@@ -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/<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 本地累计 + 服务端匿名榜合并 =====
|
||||
(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(); });
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -29,7 +29,12 @@
|
||||
data-nav-id="{{ item.id }}"
|
||||
{% if item.is_external %}target="_blank" rel="noopener noreferrer"{% endif %}>
|
||||
<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 class="card-body">
|
||||
<div class="title-row">
|
||||
|
||||
@@ -8,7 +8,13 @@
|
||||
{%- if mine is not none %} data-mine="{{ mine }}"{% endif %}
|
||||
{%- if item.is_external %} target="_blank" rel="noopener noreferrer"{% endif %}>
|
||||
<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 class="card-body">
|
||||
<div class="title-row">
|
||||
|
||||
Reference in New Issue
Block a user