Files
astro-jiao77.cn/src/components/report/ReportSidebar.astro
2025-09-30 02:09:26 +08:00

523 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
export interface Props {
title?: string;
toggleLabel?: string;
targetSelector?: string;
headingSelector?: string;
minLevel?: number;
maxLevel?: number;
defaultOpenBreakpoint?: string;
}
const {
title = '页面导航',
toggleLabel = '导航',
targetSelector = '[data-report-content]',
headingSelector,
minLevel = 2,
maxLevel = 3,
defaultOpenBreakpoint = '(min-width: 1280px)'
} = Astro.props;
const sidebarId = `report-sidebar-${Math.random().toString(36).slice(2, 10)}`;
const navListId = `${sidebarId}-list`;
const config = {
sidebarId,
navListId,
title,
toggleLabel,
targetSelector,
headingSelector: headingSelector ?? '',
minLevel,
maxLevel,
breakpoint: defaultOpenBreakpoint
};
---
<aside
id={sidebarId}
class="report-sidebar collapsed"
data-report-sidebar
data-breakpoint={defaultOpenBreakpoint}
>
<button
class="report-sidebar__toggle"
type="button"
aria-expanded="false"
aria-controls={navListId}
>
<span class="report-sidebar__toggle-icon" aria-hidden="true">☰</span>
<span class="report-sidebar__toggle-label">{toggleLabel}</span>
</button>
<div class="report-sidebar__panel" role="dialog" aria-modal="false">
<div class="report-sidebar__header">
<h2 class="report-sidebar__title">{title}</h2>
<button class="report-sidebar__close" type="button" aria-label="收起导航">
×
</button>
</div>
<nav class="report-sidebar__nav" aria-label={title}>
<ul id={navListId} class="report-sidebar__list"></ul>
</nav>
</div>
</aside>
<script is:inline define:vars={{ CONFIG: config }}>
const script = document.currentScript;
(function () {
if (!script) return;
const sidebar = document.getElementById(CONFIG.sidebarId);
if (!sidebar || sidebar.dataset.initialized === 'true') return;
sidebar.dataset.initialized = 'true';
const navList = sidebar.querySelector('.report-sidebar__list');
const toggleBtn = sidebar.querySelector('.report-sidebar__toggle');
const closeBtn = sidebar.querySelector('.report-sidebar__close');
if (!navList || !toggleBtn || !closeBtn) return;
const matchBreakpoint = window.matchMedia(CONFIG.breakpoint);
const headingLevels = Array.from({ length: CONFIG.maxLevel - CONFIG.minLevel + 1 }, (_, idx) => `h${CONFIG.minLevel + idx}`);
const selector = CONFIG.headingSelector && CONFIG.headingSelector.trim().length > 0
? CONFIG.headingSelector
: headingLevels.join(',');
const slugify = (text) => {
if (!text) return 'section';
return text
.toLowerCase()
.replace(/[\p{Extended_Pictographic}\p{Emoji_Presentation}\p{Emoji_Component}\p{Emoji}\p{Symbol}\p{Open_Punctuation}\p{Close_Punctuation}]/gu, '')
.replace(/[^a-z0-9\u4e00-\u9fa5\-\s]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || 'section';
};
const buildNavigation = () => {
const targetRoot = document.querySelector(CONFIG.targetSelector) ?? document.body;
const headings = Array.from(targetRoot.querySelectorAll(selector))
.filter((heading) => heading instanceof HTMLElement);
if (!headings.length) {
sidebar.classList.add('report-sidebar--empty');
return;
}
sidebar.classList.remove('report-sidebar--empty');
const existingIds = new Set(headings.filter((h) => h.id).map((h) => h.id));
headings.forEach((heading) => {
if (!heading.id) {
const base = slugify(heading.textContent ?? 'section');
let candidate = base;
let counter = 1;
while (existingIds.has(candidate) || document.getElementById(candidate)) {
candidate = `${base}-${counter++}`;
}
heading.id = candidate;
existingIds.add(candidate);
}
});
// 清空并重建导航
navList.innerHTML = '';
headings.forEach((heading) => {
const level = parseInt(heading.tagName.replace('H', ''), 10) || CONFIG.minLevel;
const text = heading.textContent?.trim() ?? '';
if (!text) return;
// 计算缩进层级(相对于最小层级)
const depth = Math.max(0, level - CONFIG.minLevel);
const listItem = document.createElement('li');
listItem.className = `nav-item nav-item--level-${level} nav-item--depth-${depth}`;
const link = document.createElement('a');
link.className = `nav-link nav-link--level-${level}`;
link.href = `#${heading.id}`;
link.textContent = text;
link.dataset.targetId = heading.id;
link.dataset.level = String(level);
link.dataset.depth = String(depth);
// 设置内联样式
const indentPx = depth * 20; // 每层20px缩进
let fontSize, fontWeight, color;
switch(level) {
case 2:
fontSize = '18px';
fontWeight = '700';
color = '#0f172a';
break;
case 3:
fontSize = '16px';
fontWeight = '600';
color = '#1e293b';
break;
default:
fontSize = '13px';
fontWeight = '400';
color = '#475569';
}
link.style.cssText = `
margin-left: ${indentPx}px;
font-size: ${fontSize};
font-weight: ${fontWeight};
color: ${color};
padding: ${level === 2 ? '12px 16px' : level === 3 ? '10px 14px' : '8px 12px'};
display: block;
text-decoration: none;
border-radius: 8px;
transition: all 0.2s ease;
`;
listItem.appendChild(link);
navList.appendChild(listItem);
});
setupScrollSpy(headings);
};
const setCollapsed = (collapsed) => {
sidebar.classList.toggle('collapsed', collapsed);
toggleBtn.setAttribute('aria-expanded', String(!collapsed));
};
const handleToggle = () => {
setCollapsed(!sidebar.classList.contains('collapsed'));
};
toggleBtn.addEventListener('click', handleToggle);
closeBtn.addEventListener('click', () => setCollapsed(true));
const handleLinkClick = (event) => {
const link = event.target.closest('.nav-link');
if (!link) return;
event.preventDefault();
const targetId = link.dataset.targetId;
const target = targetId ? document.getElementById(targetId) : null;
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
history.replaceState(null, '', `#${targetId}`);
}
if (!matchBreakpoint.matches) {
setCollapsed(true);
}
};
navList.addEventListener('click', handleLinkClick);
const applyBreakpointState = () => {
setCollapsed(!matchBreakpoint.matches);
};
matchBreakpoint.addEventListener('change', applyBreakpointState);
let activeLink = null;
let observer = null;
const setActiveLink = (id) => {
if (activeLink) {
activeLink.classList.remove('nav-link--active');
}
const next = navList.querySelector(`.nav-link[data-target-id="${id}"]`);
if (next) {
next.classList.add('nav-link--active');
activeLink = next;
} else {
activeLink = null;
}
};
const setupScrollSpy = (headings) => {
if (observer) {
observer.disconnect();
}
observer = new IntersectionObserver((entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
if (visible.length > 0) {
setActiveLink(visible[0].target.id);
return;
}
const sorted = entries
.slice()
.sort((a, b) => a.target.offsetTop - b.target.offsetTop);
const current = sorted.find((entry) => window.scrollY + 120 <= entry.target.offsetTop + entry.target.offsetHeight);
if (current) {
setActiveLink(current.target.id);
}
}, {
rootMargin: '-35% 0px -55% 0px',
threshold: [0, 0.25, 0.5, 0.75, 1]
});
headings.forEach((heading) => observer.observe(heading));
};
const initialize = () => {
buildNavigation();
applyBreakpointState();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize, { once: true });
} else {
initialize();
}
document.addEventListener('astro:after-swap', () => {
buildNavigation();
applyBreakpointState();
});
})();
</script>
<style>
.report-sidebar {
--sidebar-width: var(--report-sidebar-width, clamp(260px, 24vw, 320px));
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
width: var(--sidebar-width);
z-index: 40;
}
.report-sidebar__toggle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 1rem;
border-radius: 999px;
border: 1px solid rgba(91, 119, 142, 0.35);
background: rgba(255, 255, 255, 0.18);
color: #0f172a;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
transition: transform 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
backdrop-filter: blur(12px);
}
.report-sidebar__toggle:hover {
transform: translateY(-1px);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.16);
background: rgba(255, 255, 255, 0.28);
}
.report-sidebar__toggle-icon {
font-size: 1.1rem;
}
.report-sidebar__panel {
width: var(--sidebar-width);
background: rgba(255, 255, 255, 0.3);
border: 1px solid rgba(91, 119, 142, 0.25);
border-radius: 1.25rem;
padding: 1.25rem;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.15);
backdrop-filter: blur(18px);
transition: transform 0.35s ease, opacity 0.35s ease, visibility 0.35s ease;
position: sticky;
top: 6.5rem;
max-height: calc(100vh - 7.5rem);
overflow: hidden;
display: flex;
flex-direction: column;
}
.collapsed .report-sidebar__panel {
opacity: 0;
visibility: hidden;
transform: translateX(-12px);
pointer-events: none;
}
.report-sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.report-sidebar__title {
font-size: 1.125rem;
font-weight: 700;
margin: 0;
color: #0f172a;
}
.report-sidebar__close {
border: none;
background: rgba(15, 23, 42, 0.06);
color: #0f172a;
width: 2rem;
height: 2rem;
border-radius: 999px;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease, transform 0.2s ease;
}
.report-sidebar__close:hover {
background: rgba(15, 23, 42, 0.12);
transform: scale(1.05);
}
.report-sidebar__nav {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding-right: 8px;
margin-right: -8px;
}
/* 自定义滚动条样式 */
.report-sidebar__nav::-webkit-scrollbar {
width: 6px;
}
.report-sidebar__nav::-webkit-scrollbar-track {
background: rgba(148, 163, 184, 0.1);
border-radius: 3px;
}
.report-sidebar__nav::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.4);
border-radius: 3px;
}
.report-sidebar__nav::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.6);
}
.report-sidebar__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
/* 导航项基础样式 */
.nav-item {
position: relative;
margin: 2px 0;
}
.nav-link:hover {
background: rgba(59, 130, 246, 0.1) !important;
transform: translateX(4px);
}
/* 激活状态 */
.nav-link--active {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(29, 78, 216, 0.15)) !important;
color: #1d4ed8 !important;
font-weight: 700 !important;
border-left: 4px solid #3b82f6;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
}
@media (min-width: 1280px) {
.report-sidebar {
--sidebar-width: var(--report-sidebar-width, clamp(260px, 22vw, 320px));
--sidebar-inline-offset: clamp(1rem, 2.5vw, 3rem);
position: fixed;
top: 6rem;
left: calc(env(safe-area-inset-left) + var(--sidebar-inline-offset));
height: calc(100vh - 7rem);
align-items: stretch;
gap: 1rem;
}
.report-sidebar__toggle {
justify-content: center;
width: 100%;
}
.report-sidebar__panel {
position: relative;
top: auto;
width: var(--sidebar-width);
max-height: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.report-sidebar__nav {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.collapsed .report-sidebar__panel {
transform: translateX(-16px);
}
}
@media (max-width: 1279px) {
.report-sidebar {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
width: auto;
gap: 0.5rem;
align-items: flex-end;
z-index: 55;
}
.report-sidebar__toggle {
background: rgba(15, 23, 42, 0.8);
color: #f8fafc;
box-shadow: 0 15px 30px rgba(15, 23, 42, 0.35);
}
.report-sidebar__panel {
position: fixed;
bottom: 0;
right: 0;
top: auto;
left: 0;
width: 100%;
max-height: min(520px, 85vh);
border-radius: 1.5rem 1.5rem 0 0;
padding: 1.5rem;
transform: translateY(100%);
}
.collapsed .report-sidebar__panel {
transform: translateY(110%);
}
.report-sidebar__panel,
.collapsed .report-sidebar__panel {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.report-sidebar__panel,
.report-sidebar__toggle,
.nav-link {
transition: none;
}
}
</style>