""" 前台导航页视图(无需登录)。 URL 路径(挂在根路径下,便于匿名访问): - GET /site 公开导航聚合首页(按分类展示所有启用的导航) - GET /site/category/ 分类详情:展示指定分类下所有启用的导航 - 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 开始),与首页 各 ``
`` 的 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/') 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}