Files
pear-admin-flask/applications/view/public/nav.py
T
bwadmin 4d6325c70e feat(public): 首页分类移至左侧侧栏 + 大屏自适应布局
- 分类导航由顶部横条改为左侧 sticky 侧栏(≥1025px),含快速导航/我常访问/热门/书签分类/站点页面
- 首页左右去留白:.container 与 .topbar-inner 改为 max-width:100%,布局铺满视口
- 卡片网格按屏宽自适应(minmax(var(--card-min),1fr)),1920 固定 6 列
- 移除顶部 .actions 菜单(与侧栏站点页面重复),主题切换改由侧栏按钮循环切换
- 新增 get_nav_categories() 统一锚点(侧栏/顶部/抽屉/section id 四者同源),修复空分类错位;后台分类增删改/启停后失效前台缓存
2026-09-07 11:11:22 +08:00

230 lines
7.7 KiB
Python

"""
前台导航页视图(无需登录)。
URL 路径(挂在根路径下,便于匿名访问):
- GET /site 公开导航聚合首页(按分类展示所有启用的导航)
- GET /site/category/<name> 分类详情:展示指定分类下所有启用的导航
- GET /site/api/navs JSON 接口(供前端动态加载)
注意:
- 这些视图**不加** @authorize,避免 login_required 强制登录。
- 使用 url_prefix='/site' 避免与后台 / 路由冲突(后台 / 是登录后的工作台)。
"""
from collections import OrderedDict
import time
from flask import Blueprint, render_template, request, jsonify
from applications.models import Nav, NavCategory
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')
# 分类列表缓存(左侧侧栏 / 移动端抽屉共用,避免每个请求都查库)
_NAV_CATS_CACHE = {'ts': 0.0, 'data': []}
_NAV_CATS_TTL = 30.0
def get_nav_categories() -> "list[dict]":
"""返回前台导航用的分类列表(按后台 sort 排序,带稳定锚点)。
返回形如::
[{'name': '开发工具', 'icon': 'layui-icon-list',
'description': '', 'anchor': 'cat-1'}, ...]
锚点 ``anchor`` 基于「启用分类」的排序索引生成(从 1 开始),与首页
各 ``<section>`` 的 id 严格一致——即使某个分类下暂时没有导航(空分组
被过滤)也不会错位。
带 30s 进程内缓存:后台新增/调整分类后最多 30s 生效。
"""
now = time.time()
cached = _NAV_CATS_CACHE.get('data') or []
if cached and (now - _NAV_CATS_CACHE.get('ts', 0.0)) < _NAV_CATS_TTL:
return cached
try:
cats = (
NavCategory.query
.filter(NavCategory.enable == 1)
.order_by(NavCategory.sort.desc(), NavCategory.id.asc())
.all()
)
names = [c.name for c in cats]
meta = {c.name: (c.icon, c.description) for c in cats}
# 兜底:只在 Nav 表里出现、未登记到 NavCategory 的历史分类,
# 追加到末尾(与 _grouped_navs 的兜底顺序一致:按分类名升序),
# 保证侧栏锚点与首页 section 一一对应。
used = Nav.query.filter(Nav.enable == 1).with_entities(Nav.category).distinct().all()
names.extend(sorted(n for (n,) in used if n and n not in meta))
data = []
for idx, name in enumerate(names):
icon, desc = meta.get(name, (None, None))
data.append({
'name': name,
'icon': icon or 'layui-icon-list',
'description': desc or '',
'anchor': 'cat-%d' % (idx + 1),
})
except Exception: # 数据库尚未初始化(如迁移前)时不影响页面渲染
data = []
_NAV_CATS_CACHE['ts'] = now
_NAV_CATS_CACHE['data'] = data
return data
def get_cat_anchor_map() -> "dict[str, str]":
"""分类名 -> 锚点 id 的映射,供首页渲染 section id 使用。"""
return {c['name']: c['anchor'] for c in get_nav_categories()}
def clear_nav_categories_cache():
"""后台改动分类后调用:立即失效缓存。"""
_NAV_CATS_CACHE['ts'] = 0.0
_NAV_CATS_CACHE['data'] = []
@bp.get('/')
def index():
"""
公开首页:按 category 分组聚合,渲染模板
"""
return _render_index()
@bp.get('/category/<string:name>')
def category_detail(name: str):
"""
分类详情页:列出指定分类下所有启用的导航
"""
items = (
Nav.query
.filter(Nav.category == name, Nav.enable == 1)
.order_by(Nav.sort.asc(), Nav.id.asc())
.all()
)
counts = get_nav_visit_counts()
dict_items = []
for it in items:
d = it.to_dict()
d['visit_count'] = counts.get(it.id, 0)
dict_items.append(d)
return render_template(
'public/category.html',
category=name,
items=dict_items,
site_name='旺珂 · 导航',
)
@bp.get('/api/navs')
def api_navs():
"""
JSON 接口:返回按分类聚合的导航数据
可用于首页卡片动态加载、第三方嵌入等
"""
return jsonify({
'success': True,
'msg': '请求成功',
'data': _grouped_navs(),
})
def render_public_index():
"""给外部复用:渲染前台公开导航首页(无需登录)。"""
return _render_index()
def get_category_meta() -> "dict[str, dict]":
"""给外部复用:返回启用的分类元信息。"""
return _category_meta()
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,
total=total,
site_name='旺珂 · 导航',
cat_meta=_category_meta(),
cat_anchors=get_cat_anchor_map(),
top_navs=_top_navs(12),
my_navs=mine if len(mine) >= MINE_MIN_ITEMS else [],
)
def _top_navs(limit: int = 12) -> "list[dict]":
"""跨分类聚合:返回点击数 TOP N 的有效 nav 列表(按点击数降序)。
- 复用 get_nav_visit_counts() 的 60s 进程内缓存
- 0 点击时返回空列表(调用方负责隐藏整个分类块)
- 同一个 nav 不可能重复出现(按 nav.id 维度)
"""
counts = get_nav_visit_counts()
if not counts:
return []
# 取点击数最高的 N 个 nav_id(点击数相同的按 id asc 稳定排序)
top_ids = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:limit]
nav_ids = [nid for nid, _ in top_ids]
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 in nav_ids:
if nid in by_id:
d = by_id[nid]
d['visit_count'] = counts.get(nid, 0)
out.append(d)
return out
def _grouped_navs() -> "OrderedDict[str, list]":
"""按 category 分组,组内按 sort asc 排序;分组顺序优先按 NavCategory.sort 排列"""
# 先取启用的分类,按 sort 排好序
cats = (
NavCategory.query
.filter(NavCategory.enable == 1)
.order_by(NavCategory.sort.desc(), NavCategory.id.asc())
.all()
)
ordered_names = [c.name for c in cats]
cat_order = {name: idx for idx, name in enumerate(ordered_names)}
items = (
Nav.query
.filter(Nav.enable == 1)
.order_by(Nav.category.asc(), Nav.sort.asc(), Nav.id.asc())
.all()
)
# 一次性取所有 nav 的点击数(带缓存)
counts = get_nav_visit_counts()
groups = OrderedDict()
# 按 cat_order 初始化空分组,保证顺序
for name in ordered_names:
groups[name] = []
for item in items:
d = item.to_dict()
d['visit_count'] = counts.get(item.id, 0)
if item.category in groups:
groups[item.category].append(d)
else:
# 对于未在 NavCategory 登记的历史分类,放在最后
groups.setdefault(item.category, []).append(d)
# 过滤空分组(启用的分类下可能没有导航)
return OrderedDict((k, v) for k, v in groups.items() if v)
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}