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:
@@ -3,6 +3,7 @@
|
||||
|
||||
每条记录是用户点了一次某张 Nav 卡片跳出去的事件。
|
||||
- nav_id 关联 public_nav.id
|
||||
- anon_id 匿名访客 ID(后端静默下发的 cookie,不关联账号)
|
||||
- day YYYY-MM-DD 日期
|
||||
- ip 访客 IP(用于去重 / 隐私留存期控制)
|
||||
- clicked_at 点击时间
|
||||
@@ -16,6 +17,7 @@ class NavClick(db.Model):
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='记录ID')
|
||||
nav_id = db.Column(db.Integer, nullable=False, index=True, comment='被点击的导航ID')
|
||||
anon_id = db.Column(db.String(36), default='', index=True, comment='匿名访客ID')
|
||||
day = db.Column(db.String(10), default='', index=True, comment='日期 YYYY-MM-DD')
|
||||
ip = db.Column(db.String(64), default='', comment='访客IP')
|
||||
clicked_at = db.Column(db.DateTime, default=datetime.datetime.now, index=True, comment='点击时间')
|
||||
@@ -15,7 +15,10 @@ from collections import OrderedDict
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
|
||||
from applications.models import Nav, NavCategory
|
||||
from plugins.siteStats.view.stat import get_nav_visit_counts
|
||||
from plugins.siteStats.view.stat import get_nav_visit_counts, my_top_navs
|
||||
|
||||
# 「我常访问」冷启动阈值:至少点过这么多个不同网址才展示个人区
|
||||
MINE_MIN_ITEMS = 2
|
||||
|
||||
bp = Blueprint('public_nav', __name__, url_prefix='/site')
|
||||
|
||||
@@ -79,6 +82,7 @@ def get_category_meta() -> "dict[str, dict]":
|
||||
def _render_index():
|
||||
groups = _grouped_navs()
|
||||
total = sum(len(items) for items in groups.values())
|
||||
mine = my_top_navs(8)
|
||||
return render_template(
|
||||
'public/index.html',
|
||||
groups=groups,
|
||||
@@ -86,6 +90,7 @@ def _render_index():
|
||||
site_name='旺珂 · 导航',
|
||||
cat_meta=_category_meta(),
|
||||
top_navs=_top_navs(12),
|
||||
my_navs=mine if len(mine) >= MINE_MIN_ITEMS else [],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@ URL 前缀:/system/stats (与框架原路径一致);埋点用 before_req
|
||||
import datetime
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
from flask import Blueprint, render_template, request, jsonify, g
|
||||
|
||||
from applications.common.utils.rights import authorize
|
||||
from applications.extensions import db
|
||||
from applications.models import VisitLog, PageStat, NavClick
|
||||
from applications.models import VisitLog, PageStat, NavClick, Nav
|
||||
|
||||
dir_path = os.path.dirname(os.path.abspath(__file__))
|
||||
bp = Blueprint(
|
||||
@@ -159,6 +160,59 @@ def record_visit():
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
# ---------- 匿名访客 ID(方案 C 的服务端一半)----------
|
||||
#
|
||||
# 设计原则:完全无感。访客不需要登录、不需要点任何按钮,后端在第一次响应里
|
||||
# 静默下发一个 uuid4 cookie(1 年),之后所有点击都带上它。
|
||||
# 前端另有 localStorage 做「秒级」本地累计,两者取 max 合并展示。
|
||||
|
||||
ANON_COOKIE = 'wk_anon'
|
||||
ANON_MAX_AGE = 365 * 24 * 3600 # 1 年
|
||||
_HEX = set('0123456789abcdef')
|
||||
|
||||
|
||||
def _valid_anon_id(s: str) -> bool:
|
||||
s = (s or '').strip().lower()
|
||||
return len(s) == 32 and all(c in _HEX for c in s)
|
||||
|
||||
|
||||
def current_anon_id(create: bool = True) -> str:
|
||||
"""取当前请求的匿名 ID。
|
||||
|
||||
- 浏览器已有合法 cookie → 直接复用
|
||||
- 没有 → 现场生成 uuid4().hex,并在 after_request 里静默种下
|
||||
- create=False:只读取,不生成(用于「清除记录」这类不该凭空造身份的接口)
|
||||
"""
|
||||
got = getattr(g, 'anon_id', '')
|
||||
if got:
|
||||
return got
|
||||
aid = (request.cookies.get(ANON_COOKIE) or '').strip()
|
||||
if not _valid_anon_id(aid):
|
||||
if not create:
|
||||
return ''
|
||||
aid = uuid.uuid4().hex
|
||||
g.anon_new = True
|
||||
g.anon_id = aid
|
||||
return aid
|
||||
|
||||
|
||||
@site_bp.after_app_request
|
||||
def _plant_anon_cookie(resp):
|
||||
"""全站响应后处理:首次访问时把匿名 ID 写回浏览器。"""
|
||||
try:
|
||||
if getattr(g, 'anon_new', False) and getattr(g, 'anon_id', ''):
|
||||
resp.set_cookie(
|
||||
ANON_COOKIE, g.anon_id,
|
||||
max_age=ANON_MAX_AGE,
|
||||
path='/',
|
||||
httponly=True,
|
||||
samesite='Lax',
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return resp
|
||||
|
||||
|
||||
# ---------- 导航卡片点击埋点 ----------
|
||||
|
||||
# 进程级内存缓存,避免每个聚合页请求都 GROUP BY 一次
|
||||
@@ -191,7 +245,8 @@ def nav_click(nav_id):
|
||||
ip = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
||||
or request.remote_addr or '')
|
||||
day = datetime.date.today().strftime('%Y-%m-%d')
|
||||
db.session.add(NavClick(nav_id=nav_id, day=day, ip=ip[:64]))
|
||||
anon = current_anon_id() # 没有就现场生成,响应里种 cookie
|
||||
db.session.add(NavClick(nav_id=nav_id, day=day, ip=ip[:64], anon_id=anon))
|
||||
db.session.commit()
|
||||
# 让缓存尽快失效(最多延迟 60s 也无所谓,统计本来就不需要实时)
|
||||
global _visit_count_cache_at
|
||||
@@ -200,3 +255,95 @@ def nav_click(nav_id):
|
||||
db.session.rollback()
|
||||
# 204 No Content:无响应体,beacon 友好
|
||||
return ('', 204)
|
||||
|
||||
|
||||
# ---------- 「我常访问」:基于匿名 ID 的个人榜单 ----------
|
||||
|
||||
_MINE_ROWS = 400 # 每个匿名 ID 最多回溯多少条点击(防止老用户全表扫)
|
||||
|
||||
|
||||
def get_my_nav_scores(anon_id: str, limit: int = 8):
|
||||
"""返回 [(nav_id, score, count), ...],按 score 降序。
|
||||
|
||||
score 带时间衰减:7 天内权重 1.0,30 天内 0.5,更早 0.2。
|
||||
这样「常访问」会随着习惯变化自动换血,不会被半年前的一次点击钉死。
|
||||
"""
|
||||
if not anon_id:
|
||||
return []
|
||||
rows = (
|
||||
NavClick.query
|
||||
.filter(NavClick.anon_id == anon_id)
|
||||
.order_by(NavClick.clicked_at.desc())
|
||||
.limit(_MINE_ROWS)
|
||||
.all()
|
||||
)
|
||||
now = datetime.datetime.now()
|
||||
agg = {}
|
||||
for r in rows:
|
||||
hit = r.clicked_at or now
|
||||
try:
|
||||
age = (now - hit).total_seconds() / 86400.0
|
||||
except Exception:
|
||||
age = 0.0
|
||||
if age <= 7:
|
||||
w = 1.0
|
||||
elif age <= 30:
|
||||
w = 0.5
|
||||
else:
|
||||
w = 0.2
|
||||
score, cnt = agg.get(r.nav_id, (0.0, 0))
|
||||
agg[r.nav_id] = (score + w, cnt + 1)
|
||||
out = [(nid, round(s, 2), c) for nid, (s, c) in agg.items()]
|
||||
out.sort(key=lambda t: (-t[1], t[0]))
|
||||
return out[:limit]
|
||||
|
||||
|
||||
def my_top_navs(limit: int = 8) -> "list[dict]":
|
||||
"""当前访客的常用网址(有效且启用的),供首页服务端渲染。"""
|
||||
scores = get_my_nav_scores(current_anon_id(), limit)
|
||||
if not scores:
|
||||
return []
|
||||
nav_ids = [nid for nid, _, _ in scores]
|
||||
rows = Nav.query.filter(Nav.id.in_(nav_ids), Nav.enable == 1).all()
|
||||
by_id = {n.id: n.to_dict() for n in rows}
|
||||
out = []
|
||||
for nid, score, cnt in scores:
|
||||
d = by_id.get(nid)
|
||||
if not d:
|
||||
continue
|
||||
d = dict(d)
|
||||
d['mine_score'] = score
|
||||
d['mine_count'] = cnt
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
@site_bp.get('/site/api/my-top')
|
||||
def api_my_top():
|
||||
"""前端异步拉取个人榜单(localStorage 合并用)。"""
|
||||
try:
|
||||
limit = max(1, min(12, int(request.args.get('limit', 8))))
|
||||
except (TypeError, ValueError):
|
||||
limit = 8
|
||||
aid = current_anon_id(create=False)
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': '请求成功',
|
||||
'data': {
|
||||
'anon': bool(aid),
|
||||
'items': my_top_navs(limit),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@site_bp.post('/site/api/my-clear')
|
||||
def api_my_clear():
|
||||
"""清除「我」的点击记录(服务端部分;前端同时清 localStorage)。"""
|
||||
aid = current_anon_id(create=False)
|
||||
if aid:
|
||||
try:
|
||||
NavClick.query.filter(NavClick.anon_id == aid).delete()
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
return ('', 204)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""一次性脚本:给已有的 site_nav_click 表加 anon_id 列(幂等,可重复执行)。
|
||||
|
||||
用法: PYTHONPATH=. python scripts/ensure_nav_click_anon_id.py
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
from app import app
|
||||
from applications.extensions import db
|
||||
|
||||
with app.app_context():
|
||||
uri = str(db.engine.url)
|
||||
if not uri.startswith('sqlite'):
|
||||
print('非 SQLite,跳过(MySQL 请用 flask db migrate/upgrade 或手工 ALTER TABLE)')
|
||||
raise SystemExit(0)
|
||||
|
||||
# 注意:用 engine.url.database,而不是配置里的相对路径(配置可能是 sqlite:///../pear.db)
|
||||
path = db.engine.url.database or ''
|
||||
path = os.path.abspath(os.path.normpath(path))
|
||||
print('DB:', path)
|
||||
|
||||
conn = sqlite3.connect(path)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='site_nav_click'")
|
||||
if not cur.fetchone():
|
||||
print('表不存在,交给 db.create_all() 处理')
|
||||
conn.close()
|
||||
from applications.extensions import db
|
||||
from applications.models import NavClick # noqa: F401
|
||||
db.create_all()
|
||||
print('OK: 已建表(含 anon_id)')
|
||||
raise SystemExit(0)
|
||||
|
||||
cur.execute('PRAGMA table_info(site_nav_click)')
|
||||
cols = [r[1] for r in cur.fetchall()]
|
||||
print('现有列:', cols)
|
||||
|
||||
if 'anon_id' in cols:
|
||||
print('OK: anon_id 已存在,无需变更')
|
||||
else:
|
||||
cur.execute("ALTER TABLE site_nav_click ADD COLUMN anon_id VARCHAR(36) DEFAULT ''")
|
||||
cur.execute('CREATE INDEX IF NOT EXISTS ix_site_nav_click_anon_id ON site_nav_click (anon_id)')
|
||||
conn.commit()
|
||||
print('OK: 已添加 anon_id 列 + 索引')
|
||||
|
||||
cur.execute('PRAGMA table_info(site_nav_click)')
|
||||
print('变更后列:', [r[1] for r in cur.fetchall()])
|
||||
conn.close()
|
||||
@@ -0,0 +1,63 @@
|
||||
"""验证「我常访问」:服务端匿名榜 + localStorage 合并 + 清除按钮。
|
||||
|
||||
用法:先启动 Flask(python app.py),再 PYTHONPATH=. python scripts/verify_mine_section.py
|
||||
"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = 'http://127.0.0.1:5000'
|
||||
LS_KEY = 'wk_mine_clicks'
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
ctx = browser.new_context(viewport={'width': 1280, 'height': 900})
|
||||
page = ctx.new_page()
|
||||
|
||||
# ---- 1) 全新访客:没有点击记录,不该出现个人区 ----
|
||||
page.goto(BASE + '/site/', wait_until='networkidle')
|
||||
print('1) 新访客 #cat-mine 存在:', page.locator('#cat-mine').count())
|
||||
anon = [c for c in ctx.cookies() if c['name'] == 'wk_anon']
|
||||
print(' 匿名 cookie:', (anon[0]['value'][:8] + '...') if anon else '无')
|
||||
|
||||
# 取页面上真实存在的几个 nav id
|
||||
ids = page.eval_on_selector_all(
|
||||
'.section:not(#cat-mine) .card[data-nav-id]',
|
||||
'els => els.slice(0, 5).map(e => e.getAttribute("data-nav-id"))'
|
||||
)
|
||||
print(' 可用 nav id:', ids)
|
||||
|
||||
# ---- 2) 只写 localStorage(模拟 cookie 被禁 / 服务端还没同步)----
|
||||
page.evaluate(
|
||||
"""([k, a, b]) => localStorage.setItem(k, JSON.stringify({[a]: 4, [b]: 2}))""",
|
||||
[LS_KEY, ids[0], ids[1]]
|
||||
)
|
||||
page.reload(wait_until='networkidle')
|
||||
n = page.locator('#cat-mine .card').count()
|
||||
print('2) localStorage 驱动后 #cat-mine 卡片数:', n)
|
||||
print(' 顶部导航入口:', page.locator('.cat-nav-mine').count(),
|
||||
'| 抽屉入口:', page.locator('.cat-drawer-mine').count())
|
||||
if n:
|
||||
print(' 首卡文案:', page.locator('#cat-mine .card').first.inner_text().replace('\n', ' | ')[:90])
|
||||
|
||||
# ---- 3) 只点 1 个网址:冷启动阈值,不该展示 ----
|
||||
page.evaluate("k => localStorage.setItem(k, JSON.stringify({}))", LS_KEY)
|
||||
page.reload(wait_until='networkidle')
|
||||
print('3) 清空 localStorage 后 #cat-mine 可见:',
|
||||
page.locator('#cat-mine').count() and page.locator('#cat-mine').is_visible())
|
||||
|
||||
# ---- 4) 清除按钮 ----
|
||||
page.evaluate(
|
||||
"""([k, a, b]) => localStorage.setItem(k, JSON.stringify({[a]: 3, [b]: 1}))""",
|
||||
[LS_KEY, ids[0], ids[1]]
|
||||
)
|
||||
page.reload(wait_until='networkidle')
|
||||
print('4) 清除前 #cat-mine 数:', page.locator('#cat-mine').count())
|
||||
clears = []
|
||||
page.on('request', lambda r: clears.append(r.url) if '/site/api/my-clear' in r.url else None)
|
||||
page.locator('#clear-mine').first.click()
|
||||
page.wait_for_timeout(600)
|
||||
print(' 清除后 #cat-mine 数:', page.locator('#cat-mine').count(),
|
||||
'| localStorage:', page.evaluate("k => localStorage.getItem(k)", LS_KEY),
|
||||
'| 请求:', clears)
|
||||
|
||||
browser.close()
|
||||
print('DONE')
|
||||
+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');
|
||||
|
||||
+58
-57
@@ -1,10 +1,45 @@
|
||||
{% extends 'public/base.html' %}
|
||||
|
||||
{# 单张导航卡片:热门 / 个人 / 普通分类共用 #}
|
||||
{% macro nav_card(item, cls='', mine=None) %}
|
||||
<a class="card {{ cls }}"
|
||||
href="{{ item.url }}"
|
||||
data-nav-id="{{ item.id }}"
|
||||
{%- 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>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="title-row">
|
||||
<div class="title" title="{{ item.title }}">{{ item.title }}</div>
|
||||
{% if item.is_external %}
|
||||
<span class="ext-tag" title="外部链接"><i class="layui-icon layui-icon-link"></i></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="desc">{{ item.description or '暂无描述' }}</div>
|
||||
<div class="url" title="{{ item.url }}">{{ item.url }}</div>
|
||||
<div class="visit-count" data-nav-visit="{{ item.visit_count or 0 }}">
|
||||
{%- if mine is not none -%}
|
||||
我点过 {{ item.mine_count or 0 }} 次
|
||||
{%- else -%}
|
||||
{%- set vc = item.visit_count or 0 -%}
|
||||
{%- if vc >= 3 -%}访问 {{ '{:,}'.format(vc) }}
|
||||
{%- else -%}新
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-arrow"><i class="layui-icon layui-icon-right"></i></div>
|
||||
</a>
|
||||
{% endmacro %}
|
||||
|
||||
{# 分类目录条(sticky 顶部导航) #}
|
||||
{% block cat_nav %}
|
||||
{% if groups or top_navs %}
|
||||
{% if groups or top_navs or my_navs %}
|
||||
<nav class="cat-nav" aria-label="分类导航">
|
||||
<div class="cat-nav-inner">
|
||||
<div class="cat-nav-inner" id="cat-nav-inner">
|
||||
{% if my_navs %}<a href="#cat-mine" class="cat-nav-mine">★ 我常访问</a>{% endif %}
|
||||
{% if top_navs %}<a href="#cat-top" class="cat-nav-hot">🔥 热门网址</a>{% endif %}
|
||||
{% for category in groups.keys() %}
|
||||
<a href="#cat-{{ loop.index }}">{{ category }}</a>
|
||||
@@ -15,47 +50,38 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if not groups and not top_navs %}
|
||||
{% if not groups and not top_navs and not my_navs %}
|
||||
<div class="empty">
|
||||
<i class="layui-icon layui-icon-face-surprised" style="font-size:48px;color:#ccc;"></i>
|
||||
<p>暂无导航数据,请先在后台【系统管理 → 导航管理】添加。</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
{# ===== 热门网址:跨分类聚合的 TOP N ===== #}
|
||||
{# ===== 我常访问:基于匿名 ID + 本机 localStorage 的个人榜 ===== #}
|
||||
{% if my_navs %}
|
||||
<section class="section section-mine" id="cat-mine">
|
||||
<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">
|
||||
{% for item in my_navs %}
|
||||
{{ nav_card(item, 'card-mine', item.mine_score) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# ===== 热门网址:跨分类聚合的全站 TOP N ===== #}
|
||||
{% if top_navs %}
|
||||
<section class="section section-hot" id="cat-top">
|
||||
<div class="section-header">
|
||||
<h2><i class="layui-icon layui-icon-fire"></i> 热门网址</h2>
|
||||
<div class="meta">近期访问量最大的 {{ top_navs|length }} 个</div>
|
||||
<div class="meta">全站访客共同热度,前 {{ top_navs|length }} 个</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
{% for item in top_navs %}
|
||||
<a class="card card-top"
|
||||
href="{{ item.url }}"
|
||||
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>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="title-row">
|
||||
<div class="title" title="{{ item.title }}">{{ item.title }}</div>
|
||||
{% if item.is_external %}
|
||||
<span class="ext-tag" title="外部链接"><i class="layui-icon layui-icon-link"></i></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="desc">{{ item.description or '暂无描述' }}</div>
|
||||
<div class="url" title="{{ item.url }}">{{ item.url }}</div>
|
||||
<div class="visit-count" data-nav-visit="{{ item.visit_count or 0 }}">
|
||||
{%- set vc = item.visit_count or 0 -%}
|
||||
{%- if vc >= 3 -%}访问 {{ '{:,}'.format(vc) }}
|
||||
{%- else -%}新
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-arrow"><i class="layui-icon layui-icon-right"></i></div>
|
||||
</a>
|
||||
{{ nav_card(item, 'card-top') }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -73,35 +99,10 @@
|
||||
</div>
|
||||
<div class="grid">
|
||||
{% for item in items %}
|
||||
<a class="card"
|
||||
href="{{ item.url }}"
|
||||
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>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="title-row">
|
||||
<div class="title" title="{{ item.title }}">{{ item.title }}</div>
|
||||
{% if item.is_external %}
|
||||
<span class="ext-tag" title="外部链接"><i class="layui-icon layui-icon-link"></i></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="desc">{{ item.description or '暂无描述' }}</div>
|
||||
<div class="url" title="{{ item.url }}">{{ item.url }}</div>
|
||||
<div class="visit-count" data-nav-visit="{{ item.visit_count or 0 }}">
|
||||
{%- set vc = item.visit_count or 0 -%}
|
||||
{%- if vc >= 3 -%}访问 {{ '{:,}'.format(vc) }}
|
||||
{%- elif vc > 0 -%}新
|
||||
{%- else -%}新
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-arrow"><i class="layui-icon layui-icon-right"></i></div>
|
||||
</a>
|
||||
{{ nav_card(item) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user