diff --git a/applications/extensions/init_template_directives.py b/applications/extensions/init_template_directives.py index 0af8926..9d7dc28 100644 --- a/applications/extensions/init_template_directives.py +++ b/applications/extensions/init_template_directives.py @@ -15,3 +15,12 @@ def init_template_directives(app): def csrf_input(): return f'' + @app.template_global() + def nav_categories(): + """前台左侧侧栏 / 移动端抽屉共用的分类列表(带 anchor 锚点)。 + + 以函数形式提供而非 context_processor,后台模板不调用就不会查库。 + """ + from applications.view.public.nav import get_nav_categories + return get_nav_categories() + diff --git a/applications/view/public/nav.py b/applications/view/public/nav.py index 3b492cc..d6691a2 100644 --- a/applications/view/public/nav.py +++ b/applications/view/public/nav.py @@ -11,6 +11,7 @@ URL 路径(挂在根路径下,便于匿名访问): - 使用 url_prefix='/site' 避免与后台 / 路由冲突(后台 / 是登录后的工作台)。 """ from collections import OrderedDict +import time from flask import Blueprint, render_template, request, jsonify @@ -22,6 +23,70 @@ 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(): @@ -89,6 +154,7 @@ def _render_index(): 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 [], ) diff --git a/plugins/navManager/view/nav_category.py b/plugins/navManager/view/nav_category.py index b99acf5..52f75ca 100644 --- a/plugins/navManager/view/nav_category.py +++ b/plugins/navManager/view/nav_category.py @@ -26,6 +26,15 @@ bp = Blueprint( ) +def _invalidate_nav_cache(): + """分类变更后立即失效前台侧栏分类缓存(延迟导入避免循环依赖)。""" + try: + from applications.view.public.nav import clear_nav_categories_cache + clear_nav_categories_cache() + except Exception: # 缓存失效失败不影响主流程 + pass + + @bp.get('/') @authorize("system:nav-category:main") def main(): @@ -118,6 +127,7 @@ def update(): cat.sort = int(req_json.get('sort') or 0) cat.enable = int(req_json.get('enable') or 1) db.session.commit() + _invalidate_nav_cache() except Exception as e: db.session.rollback() return fail_api(msg=f'更新失败:{e}') @@ -147,6 +157,7 @@ def enable(): res = curd.enable_status(NavCategory, cat_id) if not res: return fail_api(msg='操作失败') + _invalidate_nav_cache() return success_api(msg='已启用') @@ -160,4 +171,5 @@ def dis_enable(): res = curd.disable_status(NavCategory, cat_id) if not res: return fail_api(msg='操作失败') + _invalidate_nav_cache() return success_api(msg='已禁用') diff --git a/templates/public/base.html b/templates/public/base.html index 7e8d50f..874d972 100644 --- a/templates/public/base.html +++ b/templates/public/base.html @@ -107,8 +107,31 @@ } } + /* ===== 布局变量:容器宽度 / 侧栏宽度 / 卡片最小宽度 ===== + * 默认 1200px 居中;宽屏按 1440 / 1700 / 2000 / 2560 四档逐级放大, + * 避免 2K、4K 下内容缩在中间一小条。 + */ + :root { + --container-max: 1200px; + --side-w: 220px; + --card-min: 220px; + --layout-gap: 24px; + } + @media (min-width: 1440px) { + :root { --container-max: 1360px; --side-w: 232px; --card-min: 196px; --layout-gap: 28px; } + } + @media (min-width: 1700px) { + :root { --container-max: 1600px; --side-w: 244px; --card-min: 250px; --layout-gap: 32px; } + } + @media (min-width: 2000px) { + :root { --container-max: 1880px; --side-w: 258px; --card-min: 200px; --layout-gap: 36px; } + } + @media (min-width: 2560px) { + :root { --container-max: 2240px; --side-w: 276px; --card-min: 210px; --layout-gap: 44px; } + } + * { box-sizing: border-box; } - html { -webkit-text-size-adjust: 100%; } + html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; } body { margin: 0; background: var(--bg); @@ -126,9 +149,20 @@ padding: 56px 0 40px; position: relative; } - .topbar-inner { max-width: 1200px; margin: 0 auto; padding: 0 24px; } + .topbar-inner { max-width: 100%; margin: 0 auto; padding: 0 24px; } .topbar h1 { font-size: 36px; margin: 0 0 8px; font-weight: 600; line-height: 1.2; } .topbar .sub { font-size: 15px; opacity: 0.92; } + /* 宽屏:标题与内边距同步放大,避免头重脚轻 */ + @media (min-width: 1700px) { + .topbar { padding: 68px 0 52px; } + .topbar h1 { font-size: 44px; } + .topbar .sub { font-size: 17px; } + .site-search { max-width: 680px; } + } + @media (min-width: 2560px) { + .topbar { padding: 84px 0 64px; } + .topbar h1 { font-size: 52px; } + } .topbar .actions { margin-top: 18px; display: flex; flex-wrap: wrap; gap: 8px; } .topbar .actions a, .topbar .actions button { display: inline-flex; @@ -260,7 +294,7 @@ } .site-search .search-btn:hover { opacity: 0.9; } - .container { max-width: 1200px; margin: 0 auto; padding: 0 24px; } + .container { max-width: 100%; margin: 0 auto; padding: 0 24px; } /* ===== 分类目录条(横向滚动) ===== */ .cat-nav { @@ -357,8 +391,100 @@ .cat-drawer-list li a:active { background: rgba(22,186,170,0.08); } .cat-drawer-list li a .arrow { color: var(--text-muted); font-size: 13px; } + /* ===== 布局:左分类侧栏 + 右内容区 ===== */ + .layout { + display: flex; + align-items: flex-start; + gap: var(--layout-gap); + } + .content { flex: 1 1 auto; min-width: 0; padding-top: 8px; } + + .side-nav { + flex: 0 0 var(--side-w); + width: var(--side-w); + position: sticky; + top: 20px; + max-height: calc(100vh - 40px); + overflow-y: auto; + overscroll-behavior: contain; + padding: 8px 0 20px; + } + .side-nav::-webkit-scrollbar { width: 4px; } + .side-nav::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + + .side-block { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: var(--shadow); + padding: 13px 10px; + margin-bottom: 14px; + } + .side-title { + display: flex; align-items: center; gap: 6px; + font-size: 12px; font-weight: 600; + color: var(--text-muted); + letter-spacing: 0.5px; + padding: 0 8px 8px; + margin-bottom: 4px; + border-bottom: 1px solid var(--border); + } + .side-title i { color: var(--brand); font-size: 14px; } + .side-list { list-style: none; margin: 0; padding: 4px 0 0; } + .side-list li a { + display: flex; align-items: center; gap: 9px; + padding: 8px 10px; + border-radius: 8px; + font-size: 14px; + color: var(--text); + text-decoration: none; + transition: background 0.15s, color 0.15s; + } + .side-list li a:hover { background: rgba(22,186,170,0.10); color: var(--brand); } + .side-list li a.active { + background: rgba(22,186,170,0.16); + color: var(--brand); + font-weight: 600; + box-shadow: inset 3px 0 0 var(--brand); + } + .side-list li a .side-ico { + color: var(--brand); font-size: 16px; + width: 18px; text-align: center; flex-shrink: 0; + } + .side-list li a .side-txt { + flex: 1; min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .side-list li a .side-count { + font-size: 11px; color: var(--text-soft); flex-shrink: 0; + } + /* 搜索过滤:分类无命中时整项隐藏 */ + .side-list li.hidden-by-search { display: none; } + + /* 侧栏特殊项:我常访问(紫)/ 热门(橙) */ + .side-list a.side-mine, .side-list a.side-mine .side-ico { color: #8b5cf6; } + .side-list a.side-mine:hover, .side-list a.side-mine.active { + background: rgba(139,92,246,0.14); box-shadow: inset 3px 0 0 #8b5cf6; + } + .side-list a.side-hot, .side-list a.side-hot .side-ico { color: #ff6b35; } + .side-list a.side-hot:hover, .side-list a.side-hot.active { + background: rgba(255,107,53,0.13); box-shadow: inset 3px 0 0 #ff6b35; + } + + /* 桌面(≥1025px):用左侧侧栏替代顶部横条 */ + @media (min-width: 1025px) { + .cat-nav { display: none; } + } + /* 平板/手机(≤1024px):侧栏收起,回到顶部横条 + 抽屉 */ + @media (max-width: 1024px) { + .layout { display: block; } + .side-nav { display: none; } + /* 顶部横条是 sticky 的,锚点跳转要留出它的高度 */ + .section { scroll-margin-top: 64px; } + } + /* ===== Section ===== */ - .section { margin: 32px 0; scroll-margin-top: 70px; } + .section { margin: 32px 0; scroll-margin-top: 24px; } .section-header { display: flex; align-items: baseline; @@ -375,7 +501,7 @@ /* ===== Grid ===== */ .grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(var(--card-min), 1fr)); gap: 16px; } .card { @@ -676,7 +802,7 @@ /* 手机端:横向分类 Tab 隐藏(用抽屉替代) */ .cat-nav { display: none; } - .section { margin: 18px 0; scroll-margin-top: 56px; } + .section { margin: 18px 0; scroll-margin-top: 16px; } .section-header { padding-left: 10px; margin-bottom: 12px; } .section-header h2 { font-size: 16px; } .section-header .meta { font-size: 12px; } @@ -773,35 +899,84 @@ - -
- 🏠 首页 - 📖 关于 - 🔗 友链 - 🔐 登录后台 - {# 主题切换下拉 #} -
- -
    -
  • 蓝绿(默认)
  • -
  • 暮色橙
  • -
  • 森林绿
  • -
  • 暗色
  • -
  • 🔄 跟随系统
  • -
-
-
{% block cat_nav %}{% endblock %} -
- {% block content %}{% endblock %} -
+ {# 首页时锚点直接指向本页 section;其他页面(关于/友链/分类详情)跳回首页对应位置 #} + {% set is_home = request.endpoint in ['public_nav.index', 'index.index'] %} + {% set home_url = url_for('public_nav.index') %} + {% set anchor_base = '#' if is_home else home_url ~ '#' %} + +
+ {# ===== 左侧分类侧栏:桌面常驻(≥1025px);平板/手机隐藏,改用顶部横条 + 抽屉 ===== #} + + +
+ {% block content %}{% endblock %} +
+