feat(site): 方案 C - 「我常访问」个人榜(匿名 ID + localStorage 合并)
- NavClick 加 anon_id 字段(已为历史表自动迁移) - 后端静默下发 wk_anon cookie(uuid4,1 年,HttpOnly) - 点击埋点写入 anon_id;新增 GET /site/api/my-top 与 POST /site/api/my-clear - 首页服务端渲染「我常访问」区(时间衰减加权分),与「热门网址」共显 - 前端 localStorage 累计 + 与服务端榜取 max 合并;冷启动阈值 2 个网址 - 一键清除:清 localStorage + 服务端删该匿名 ID 的全部记录 - 移动端抽屉、顶部导航同步加「我常访问」入口 - 底部隐私说明更新
This commit is contained in:
+194
-1
@@ -422,6 +422,31 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== 我常访问(个人榜):紫色系,与热门的橙色区分 ===== */
|
||||
.section-mine { margin-top: 8px; }
|
||||
.section-mine .section-header h2 i { color: #8b5cf6; margin-right: 2px; }
|
||||
.section-mine .section-header .meta a { color: var(--text-muted); text-decoration: underline; }
|
||||
.section-mine .section-header .meta a:hover { color: #8b5cf6; }
|
||||
.card-mine {
|
||||
border-color: rgba(139, 92, 246, 0.35);
|
||||
background: linear-gradient(135deg, var(--card-bg) 0%, rgba(139, 92, 246, 0.06) 100%);
|
||||
position: relative;
|
||||
}
|
||||
.card-mine::before {
|
||||
content: '';
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 3px; height: 100%;
|
||||
background: #8b5cf6;
|
||||
border-radius: 10px 0 0 10px;
|
||||
}
|
||||
.card-mine:hover {
|
||||
border-color: #8b5cf6;
|
||||
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.18);
|
||||
}
|
||||
.card-mine .visit-count { color: #8b5cf6; }
|
||||
.cat-nav-inner a.cat-nav-mine { color: #8b5cf6; font-weight: 600; }
|
||||
.cat-drawer-list a.cat-drawer-mine { color: #8b5cf6; font-weight: 600; }
|
||||
|
||||
.card .icon-wrap {
|
||||
width: 42px; height: 42px;
|
||||
background: rgba(22, 186, 170, 0.1);
|
||||
@@ -780,7 +805,7 @@
|
||||
|
||||
<footer class="site-footer">
|
||||
<div>© 2026 旺珂 · 基于 <a href="https://gitea.bwhome.top/bwadmin/pear-admin-flask">Pear Admin Flask</a> 构建</div>
|
||||
<div class="footer-privacy">本站使用匿名访问统计,仅记录点击次数,不关联个人身份。</div>
|
||||
<div class="footer-privacy">本站使用匿名统计:会生成一个随机匿名 ID 用于记住你的常用网址,不关联任何个人信息,可在「我常访问」里一键清除。</div>
|
||||
</footer>
|
||||
|
||||
<button class="to-top" id="to-top" aria-label="回到顶部" title="回到顶部">↑</button>
|
||||
@@ -796,6 +821,22 @@
|
||||
<button type="button" class="close" id="cat-drawer-close" aria-label="关闭">×</button>
|
||||
</div>
|
||||
|
||||
{# 我常访问入口(仅首页,且必须传了 my_navs 才渲染)#}
|
||||
{% if my_navs is defined and my_navs %}
|
||||
<div class="cat-drawer-section">
|
||||
<div class="cat-drawer-title"><i class="layui-icon layui-icon-star-fill"></i> 我的</div>
|
||||
<ul class="cat-drawer-list">
|
||||
<li>
|
||||
<a href="#cat-mine" class="cat-drawer-mine">
|
||||
<i class="layui-icon cat-icon layui-icon-star-fill"></i>
|
||||
<span>我常访问</span>
|
||||
<span class="arrow">→</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# 热门网址入口(仅首页,且首页必须传 top_navs 才渲染)#}
|
||||
{% if top_navs is defined and top_navs %}
|
||||
<div class="cat-drawer-section">
|
||||
@@ -1071,6 +1112,8 @@
|
||||
card.addEventListener('click', function(){
|
||||
var navId = card.getAttribute('data-nav-id');
|
||||
if (!navId) return;
|
||||
// 本机 localStorage 立即 +1,「我常访问」下次进页面就生效
|
||||
try { if (window.__wkMineBump) window.__wkMineBump(navId); } catch(e) {}
|
||||
try {
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon('/site/nav/' + navId + '/click');
|
||||
@@ -1084,6 +1127,8 @@
|
||||
function bindAll(){
|
||||
document.querySelectorAll('.card[data-nav-id]').forEach(bindNavClick);
|
||||
}
|
||||
window.__wkBindNavClick = bindNavClick;
|
||||
window.__wkBindNavAll = bindAll;
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bindAll);
|
||||
} else {
|
||||
@@ -1091,6 +1136,154 @@
|
||||
}
|
||||
})();
|
||||
|
||||
// ===== 「我常访问」:localStorage 本地累计 + 服务端匿名榜合并 =====
|
||||
(function(){
|
||||
var KEY = 'wk_mine_clicks';
|
||||
var MIN_ITEMS = 2; // 冷启动:至少点过 2 个不同网址才展示
|
||||
var MAX_ITEMS = 8;
|
||||
|
||||
function load(){
|
||||
try { return JSON.parse(localStorage.getItem(KEY) || '{}') || {}; }
|
||||
catch(e) { return {}; }
|
||||
}
|
||||
function save(m){
|
||||
try { localStorage.setItem(KEY, JSON.stringify(m)); } catch(e) {}
|
||||
}
|
||||
window.__wkMineBump = function(navId){
|
||||
if (!navId) return;
|
||||
var m = load();
|
||||
m[navId] = (m[navId] || 0) + 1;
|
||||
save(m);
|
||||
};
|
||||
|
||||
function findSourceCard(id){
|
||||
var all = document.querySelectorAll('.card[data-nav-id="' + id + '"]');
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
if (!all[i].closest('#cat-mine')) return all[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addCatNavEntry(){
|
||||
var inner = document.getElementById('cat-nav-inner');
|
||||
if (!inner || inner.querySelector('.cat-nav-mine')) return;
|
||||
var a = document.createElement('a');
|
||||
a.href = '#cat-mine';
|
||||
a.className = 'cat-nav-mine';
|
||||
a.textContent = '★ 我常访问';
|
||||
inner.insertBefore(a, inner.firstChild);
|
||||
}
|
||||
|
||||
function addDrawerEntry(){
|
||||
var panel = document.querySelector('.cat-drawer-panel');
|
||||
if (!panel || panel.querySelector('.cat-drawer-mine')) return;
|
||||
var sec = document.createElement('div');
|
||||
sec.className = 'cat-drawer-section';
|
||||
sec.innerHTML =
|
||||
'<div class="cat-drawer-title"><i class="layui-icon layui-icon-star-fill"></i> 我的</div>' +
|
||||
'<ul class="cat-drawer-list"><li>' +
|
||||
'<a href="#cat-mine" class="cat-drawer-mine">' +
|
||||
'<i class="layui-icon cat-icon layui-icon-star-fill"></i>' +
|
||||
'<span>我常访问</span><span class="arrow">→</span></a></li></ul>';
|
||||
panel.insertBefore(sec, panel.children[1] || null);
|
||||
}
|
||||
|
||||
function removeEntries(){
|
||||
var n = document.querySelector('.cat-nav-mine');
|
||||
if (n && n.parentNode) n.parentNode.removeChild(n);
|
||||
var d = document.querySelector('.cat-drawer-mine');
|
||||
if (d) {
|
||||
var s = d.closest('.cat-drawer-section');
|
||||
if (s && s.parentNode) s.parentNode.removeChild(s);
|
||||
}
|
||||
}
|
||||
|
||||
function render(){
|
||||
var main = document.querySelector('main.container');
|
||||
if (!main) return;
|
||||
|
||||
var local = load();
|
||||
var score = {};
|
||||
|
||||
// 1) 服务端已经渲染出来的个人卡片(带 data-mine 加权分)
|
||||
var sec = document.getElementById('cat-mine');
|
||||
if (sec) {
|
||||
sec.querySelectorAll('.card[data-nav-id]').forEach(function(c){
|
||||
var id = c.getAttribute('data-nav-id');
|
||||
var v = parseFloat(c.getAttribute('data-mine') || '0') || 0;
|
||||
if (v > (score[id] || 0)) score[id] = v;
|
||||
});
|
||||
}
|
||||
// 2) 本机 localStorage(含服务端还没同步到的点击)
|
||||
Object.keys(local).forEach(function(id){
|
||||
var v = local[id] | 0;
|
||||
if (v > (score[id] || 0)) score[id] = v;
|
||||
});
|
||||
|
||||
var ranked = Object.keys(score)
|
||||
.filter(function(id){ return (score[id] || 0) > 0; })
|
||||
.sort(function(a, b){ return (score[b] - score[a]) || (a - b); })
|
||||
.slice(0, MAX_ITEMS);
|
||||
|
||||
if (ranked.length < MIN_ITEMS) {
|
||||
if (sec) sec.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sec) {
|
||||
sec = document.createElement('section');
|
||||
sec.className = 'section section-mine';
|
||||
sec.id = 'cat-mine';
|
||||
sec.innerHTML =
|
||||
'<div class="section-header">' +
|
||||
'<h2><i class="layui-icon layui-icon-star-fill"></i> 我常访问</h2>' +
|
||||
'<div class="meta">只在这台设备上统计 · <a href="javascript:;" id="clear-mine">清除</a></div>' +
|
||||
'</div><div class="grid" id="mine-grid"></div>';
|
||||
main.insertBefore(sec, main.firstChild);
|
||||
addCatNavEntry();
|
||||
addDrawerEntry();
|
||||
}
|
||||
sec.style.display = '';
|
||||
|
||||
var grid = sec.querySelector('.grid');
|
||||
grid.innerHTML = '';
|
||||
ranked.forEach(function(id){
|
||||
var src = findSourceCard(id);
|
||||
if (!src) return;
|
||||
var clone = src.cloneNode(true);
|
||||
clone.classList.add('card-mine');
|
||||
clone.classList.remove('card-top');
|
||||
clone.setAttribute('data-mine', score[id]);
|
||||
var vc = clone.querySelector('.visit-count');
|
||||
if (vc) vc.textContent = '我点过 ' + Math.round(score[id]) + ' 次';
|
||||
grid.appendChild(clone);
|
||||
if (window.__wkBindNavClick) window.__wkBindNavClick(clone);
|
||||
});
|
||||
}
|
||||
|
||||
// 清除我的记录:清 localStorage + 通知服务端删匿名点击
|
||||
document.addEventListener('click', function(e){
|
||||
var t = e.target;
|
||||
if (!t) return;
|
||||
if (t.id !== 'clear-mine' && !(t.closest && t.closest('#clear-mine'))) return;
|
||||
e.preventDefault();
|
||||
try { localStorage.removeItem(KEY); } catch(err) {}
|
||||
try {
|
||||
if (navigator.sendBeacon) navigator.sendBeacon('/site/api/my-clear');
|
||||
else fetch('/site/api/my-clear', {method:'POST', keepalive:true, credentials:'same-origin'});
|
||||
} catch(err) {}
|
||||
removeEntries();
|
||||
var sec = document.getElementById('cat-mine');
|
||||
if (sec && sec.parentNode) sec.parentNode.removeChild(sec);
|
||||
});
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', render);
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
})();
|
||||
|
||||
// ===== 全局搜索:前端实时过滤卡片,并高亮分类 Tab =====
|
||||
(function(){
|
||||
var form = document.getElementById('site-search');
|
||||
|
||||
Reference in New Issue
Block a user