feat(public): 首页分类移至左侧侧栏 + 大屏自适应布局

- 分类导航由顶部横条改为左侧 sticky 侧栏(≥1025px),含快速导航/我常访问/热门/书签分类/站点页面
- 首页左右去留白:.container 与 .topbar-inner 改为 max-width:100%,布局铺满视口
- 卡片网格按屏宽自适应(minmax(var(--card-min),1fr)),1920 固定 6 列
- 移除顶部 .actions 菜单(与侧栏站点页面重复),主题切换改由侧栏按钮循环切换
- 新增 get_nav_categories() 统一锚点(侧栏/顶部/抽屉/section id 四者同源),修复空分类错位;后台分类增删改/启停后失效前台缓存
This commit is contained in:
2026-09-07 11:11:22 +08:00
parent 1a33648b9a
commit 4d6325c70e
5 changed files with 406 additions and 71 deletions
+313 -67
View File
@@ -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 @@
<input type="search" name="q" id="search-input" placeholder="输入书签关键词进行搜索" autocomplete="off" aria-label="搜索书签">
<button type="submit" class="search-btn" aria-label="搜索">搜索</button>
</form>
<div class="actions">
<a href="{{ url_for('public_nav.index') }}">🏠 首页</a>
<a href="{{ url_for('public_about.about') }}">📖 关于</a>
<a href="{{ url_for('public_friend.friend_index') }}">🔗 友链</a>
<a href="/system/passport/login">🔐 登录后台</a>
{# 主题切换下拉 #}
<div class="theme-picker">
<button type="button" id="theme-toggle" title="切换主题" aria-haspopup="listbox" aria-controls="theme-menu">
<span id="theme-toggle-label">🎨 主题</span>
<i class="layui-icon layui-icon-down" style="font-size:12px;"></i>
</button>
<ul class="theme-menu" id="theme-menu" role="listbox">
<li role="option" data-theme="default"><span class="dot dot-default"></span>蓝绿(默认)</li>
<li role="option" data-theme="sunset"><span class="dot dot-sunset"></span>暮色橙</li>
<li role="option" data-theme="forest"><span class="dot dot-forest"></span>森林绿</li>
<li role="option" data-theme="dark"><span class="dot dot-dark"></span>暗色</li>
<li role="option" data-theme="auto"><span class="dot dot-auto"></span>🔄 跟随系统</li>
</ul>
</div>
</div>
</div>
</header>
{% block cat_nav %}{% endblock %}
<main class="container">
{% block content %}{% endblock %}
</main>
{# 首页时锚点直接指向本页 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 ~ '#' %}
<div class="container layout">
{# ===== 左侧分类侧栏:桌面常驻(≥1025px);平板/手机隐藏,改用顶部横条 + 抽屉 ===== #}
<aside class="side-nav" id="side-nav" aria-label="分类导航"{% if is_home %} data-home="1"{% endif %}>
{% if my_navs is defined and my_navs %}
<div class="side-block side-mine-block">
<div class="side-title"><i class="layui-icon layui-icon-star-fill"></i> 我的</div>
<ul class="side-list">
<li><a href="{{ anchor_base }}cat-mine" class="side-mine" data-side-anchor="cat-mine">
<i class="layui-icon side-ico layui-icon-star-fill"></i>
<span class="side-txt">我常访问</span>
</a></li>
</ul>
</div>
{% endif %}
{% if top_navs is defined and top_navs %}
<div class="side-block side-hot-block">
<div class="side-title"><i class="layui-icon layui-icon-fire"></i> 热门</div>
<ul class="side-list">
<li><a href="{{ anchor_base }}cat-top" class="side-hot" data-side-anchor="cat-top">
<i class="layui-icon side-ico layui-icon-fire"></i>
<span class="side-txt">热门网址</span>
</a></li>
</ul>
</div>
{% endif %}
<div class="side-block" id="side-cat-block">
<div class="side-title"><i class="layui-icon layui-icon-list"></i> 书签分类</div>
<ul class="side-list" id="side-cat-list">
{% for c in nav_categories() %}
<li>
<a href="{{ anchor_base }}{{ c.anchor }}" data-side-anchor="{{ c.anchor }}" title="{{ c.name }}"{% if category is defined and category == c.name %} class="active"{% endif %}>
<i class="layui-icon side-ico {{ c.icon }}"></i>
<span class="side-txt">{{ c.name }}</span>
<span class="side-count"></span>
</a>
</li>
{% endfor %}
</ul>
</div>
<div class="side-block">
<div class="side-title"><i class="layui-icon layui-icon-link"></i> 站点页面</div>
<ul class="side-list">
<li><a href="{{ home_url }}">
<i class="layui-icon side-ico layui-icon-home"></i>
<span class="side-txt">首页</span></a></li>
<li><a href="{{ url_for('public_about.about') }}">
<i class="layui-icon side-ico layui-icon-about"></i>
<span class="side-txt">关于本站</span></a></li>
<li><a href="{{ url_for('public_friend.friend_index') }}">
<i class="layui-icon side-ico layui-icon-website"></i>
<span class="side-txt">友情链接</span></a></li>
<li><a href="/system/passport/login">
<i class="layui-icon side-ico layui-icon-password"></i>
<span class="side-txt">登录后台</span></a></li>
<li><a href="javascript:void(0)" id="side-theme-toggle">
<i class="layui-icon side-ico layui-icon-light"></i>
<span class="side-txt">切换主题</span></a></li>
</ul>
</div>
</aside>
<main class="content">
{% block content %}{% endblock %}
</main>
</div>
<footer class="site-footer">
<div>© 2026 旺珂 · 基于 <a href="https://gitea.bwhome.top/bwadmin/pear-admin-flask">Pear Admin Flask</a> 构建</div>
@@ -827,7 +1002,7 @@
<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">
<a href="{{ anchor_base }}cat-mine" class="cat-drawer-mine">
<i class="layui-icon cat-icon layui-icon-star-fill"></i>
<span>我常访问</span>
<span class="arrow"></span>
@@ -843,7 +1018,7 @@
<div class="cat-drawer-title"><i class="layui-icon layui-icon-fire"></i> 热门</div>
<ul class="cat-drawer-list">
<li>
<a href="#cat-top" class="cat-drawer-hot">
<a href="{{ anchor_base }}cat-top" class="cat-drawer-hot">
<i class="layui-icon cat-icon layui-icon-fire"></i>
<span>热门网址</span>
<span class="arrow"></span>
@@ -854,30 +1029,20 @@
{% endif %}
{# 分类区 #}
{% if cat_meta is defined %}
<div class="cat-drawer-section">
<div class="cat-drawer-title"><i class="layui-icon layui-icon-list"></i> 书签分类</div>
<ul class="cat-drawer-list" id="cat-drawer-list">
{% for category in groups.keys() %}
{% set meta = cat_meta.get(category, {}) %}
{% for c in nav_categories() %}
<li>
<a href="#cat-{{ loop.index }}">
<i class="layui-icon cat-icon {{ meta.icon|default('layui-icon-list') }}"></i>
<span>{{ category }}</span>
<a href="{{ anchor_base }}{{ c.anchor }}" data-side-anchor="{{ c.anchor }}">
<i class="layui-icon cat-icon {{ c.icon }}"></i>
<span>{{ c.name }}</span>
<span class="arrow"></span>
</a>
</li>
{% endfor %}
</ul>
</div>
{% else %}
<div class="cat-drawer-section">
<div class="cat-drawer-title"><i class="layui-icon layui-icon-list"></i> 书签分类</div>
<ul class="cat-drawer-list" id="cat-drawer-list">
{# 由 JS 从 .cat-nav a 同步过来(无 cat_meta 时兜底) #}
</ul>
</div>
{% endif %}
{# 其他页面入口(关于 / 友链 / 后台 / 主题) #}
<div class="cat-drawer-section">
@@ -1006,27 +1171,49 @@
window.scrollTo({top: 0, behavior: 'smooth'});
});
}
// 目录高亮当前 section
var navLinks = document.querySelectorAll('.cat-nav a[href^="#"]');
if (navLinks.length) {
var sections = [];
navLinks.forEach(function(a){
var id = a.getAttribute('href').slice(1);
var el = document.getElementById(id);
if (el) sections.push({link: a, el: el});
// 目录高亮当前 section:左侧侧栏(桌面)/ 顶部横条(平板)同步高亮
(function(){
var allLinks = [].slice.call(
document.querySelectorAll('.cat-nav a[href^="#"], .side-nav a[href^="#"]')
);
if (!allLinks.length) return;
var map = {}, sections = [];
allLinks.forEach(function(a){
var id = (a.getAttribute('href') || '').split('#').pop();
var el = id ? document.getElementById(id) : null;
if (!el) return;
if (!map[id]) { map[id] = []; sections.push({id: id, el: el}); }
map[id].push(a);
});
if (!sections.length) return;
function onScroll(){
var y = window.scrollY + 100;
var active = sections[0];
for (var i = 0; i < sections.length; i++) {
if (sections[i].el.offsetTop <= y) active = sections[i];
var y = window.scrollY + 120;
var activeId = sections[0].id;
sections.forEach(function(s){
// 用 rect 计算,兼容 section 位于 flex 子容器内的情况
var top = s.el.getBoundingClientRect().top + window.scrollY;
if (top <= y) activeId = s.id;
});
allLinks.forEach(function(a){ a.classList.remove('active'); });
(map[activeId] || []).forEach(function(a){ a.classList.add('active'); });
// 侧栏自身可滚动时,保证激活项可见
var activeLink = (map[activeId] || [])[0];
if (activeLink && activeLink.closest) {
var side = activeLink.closest('.side-nav');
if (side && side.scrollHeight > side.clientHeight) {
var lr = activeLink.getBoundingClientRect();
var sr = side.getBoundingClientRect();
if (lr.top < sr.top) side.scrollTop -= (sr.top - lr.top) + 8;
else if (lr.bottom > sr.bottom) side.scrollTop += (lr.bottom - sr.bottom) + 8;
}
}
navLinks.forEach(function(a){ a.classList.remove('active'); });
if (active) active.link.classList.add('active');
}
window.addEventListener('scroll', onScroll, {passive: true});
window.addEventListener('resize', onScroll);
onScroll();
}
})();
// ===== 移动端分类抽屉 =====
(function(){
@@ -1188,6 +1375,21 @@
panel.insertBefore(sec, panel.children[1] || null);
}
// 左侧侧栏里补一个「我常访问」入口(桌面端)
function addSideNavEntry(){
var nav = document.getElementById('side-nav');
if (!nav || nav.querySelector('.side-mine')) return;
var block = document.createElement('div');
block.className = 'side-block side-mine-block';
block.innerHTML =
'<div class="side-title"><i class="layui-icon layui-icon-star-fill"></i> 我的</div>' +
'<ul class="side-list"><li>' +
'<a href="#cat-mine" class="side-mine" data-side-anchor="cat-mine">' +
'<i class="layui-icon side-ico layui-icon-star-fill"></i>' +
'<span class="side-txt">我常访问</span></a></li></ul>';
nav.insertBefore(block, nav.firstChild);
}
function removeEntries(){
var n = document.querySelector('.cat-nav-mine');
if (n && n.parentNode) n.parentNode.removeChild(n);
@@ -1196,10 +1398,12 @@
var s = d.closest('.cat-drawer-section');
if (s && s.parentNode) s.parentNode.removeChild(s);
}
var sb = document.querySelector('.side-mine-block');
if (sb && sb.parentNode) sb.parentNode.removeChild(sb);
}
function render(){
var main = document.querySelector('main.container');
var main = document.querySelector('main.content') || document.querySelector('main');
if (!main) return;
var local = load();
@@ -1242,6 +1446,7 @@
main.insertBefore(sec, main.firstChild);
addCatNavEntry();
addDrawerEntry();
addSideNavEntry();
}
sec.style.display = '';
@@ -1309,6 +1514,18 @@
var visible = sec.querySelectorAll('.card:not([style*="display: none"])').length;
sec.style.display = visible ? '' : 'none';
});
// 同步左侧侧栏 / 抽屉:刷新计数,并隐藏没有命中的分类
document.querySelectorAll('a[data-side-anchor]').forEach(function(a){
var id = a.getAttribute('data-side-anchor');
var sec = document.getElementById(id);
var li = a.parentNode;
if (!sec || !li) return; // 本页无对应 section(空分类 / 非首页)保持原样
var n = sec.querySelectorAll('.card:not([style*="display: none"])').length;
var cnt = a.querySelector('.side-count');
if (cnt) cnt.textContent = n ? String(n) : '';
li.style.display = n ? '' : 'none';
});
}
input.addEventListener('input', function(){
@@ -1324,13 +1541,42 @@
}
});
// 页面加载时读取 URL 的 ?q=xxx 自动过滤
// 首页:隐藏没有对应 section 的「空分类」项(侧栏 + 抽屉)
var sideNav = document.getElementById('side-nav');
if (sideNav && sideNav.getAttribute('data-home') === '1') {
document.querySelectorAll('a[data-side-anchor]').forEach(function(a){
var sec = document.getElementById(a.getAttribute('data-side-anchor'));
if (!sec && a.parentNode) a.parentNode.style.display = 'none';
});
}
// 初始化:填充侧栏计数;同时读取 URL 的 ?q=xxx 自动过滤
var params = new URLSearchParams(window.location.search);
var q = params.get('q');
if (q) {
input.value = q;
filter(q);
}
if (q) input.value = q;
filter(q || '');
})();
// ===== 侧栏「切换主题」:循环切换到下一个主题 =====
(function(){
var btn = document.getElementById('side-theme-toggle');
if (!btn) return;
btn.addEventListener('click', function(e){
e.preventDefault();
var VALID = ['default', 'sunset', 'forest', 'dark', 'auto'];
var LABEL_MAP = {default:'🎨 蓝绿', sunset:'🎨 暮色', forest:'🎨 森林', dark:'🎨 暗色', auto:'🎨 跟随系统'};
var cur = localStorage.getItem('bw-theme') || 'default';
var next = VALID[(VALID.indexOf(cur) + 1) % VALID.length];
var isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
var resolved = (next === 'auto') ? (isDark ? 'dark' : 'default') : next;
document.documentElement.setAttribute('data-theme', resolved);
var label = document.getElementById('theme-toggle-label');
if (label) label.textContent = LABEL_MAP[next];
localStorage.setItem('bw-theme', next);
document.querySelectorAll('.theme-menu li').forEach(function(li){
li.classList.toggle('active', li.getAttribute('data-theme') === next);
});
});
})();
</script>
</body>
+6 -4
View File
@@ -34,15 +34,16 @@
</a>
{% endmacro %}
{# 分类目录条sticky 顶部导航) #}
{# 分类目录条:仅平板(601–1024px)可见,桌面走左侧侧栏、手机走抽屉 #}
{% block cat_nav %}
{% if groups or top_navs or my_navs %}
{% set anchors = cat_anchors or {} %}
<nav class="cat-nav" aria-label="分类导航">
<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>
<a href="#{{ anchors.get(category) or ('cat-' ~ loop.index) }}">{{ category }}</a>
{% endfor %}
</div>
</nav>
@@ -88,8 +89,9 @@
{% endif %}
{% for category, items in groups.items() %}
{% set cat_id = 'cat-' ~ loop.index %}
<section class="section" id="{{ cat_id }}">
{# 锚点与左侧侧栏、顶部横条、移动端抽屉共用同一套 id #}
{% set cat_id = (cat_anchors or {}).get(category) or ('cat-' ~ loop.index) %}
<section class="section" id="{{ cat_id }}" data-cat="{{ category }}">
<div class="section-header">
<h2>{{ category }}</h2>
<div class="meta">