mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-04 01:10:36 +00:00
272 lines
6.8 KiB
Vue
272 lines
6.8 KiB
Vue
<template>
|
|
<nav v-if="visible" class="section-nav" :aria-label="navLabel">
|
|
<button
|
|
v-for="section in sections"
|
|
:key="section.id"
|
|
class="section-nav-dot"
|
|
:class="{ active: activeSection === section.id }"
|
|
@click="scrollTo(section.id)"
|
|
:title="section.label"
|
|
:aria-label="(isEn ? 'Jump to ' : '跳转到') + section.label"
|
|
:aria-current="activeSection === section.id ? 'location' : undefined"
|
|
>
|
|
<span class="section-nav-dot-inner" aria-hidden="true"></span>
|
|
<span class="section-nav-label">{{ section.label }}</span>
|
|
</button>
|
|
</nav>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
import { useData } from 'vitepress'
|
|
|
|
interface Section {
|
|
id: string
|
|
label: string
|
|
}
|
|
|
|
const { lang } = useData()
|
|
const isEn = computed(() => lang.value === 'en')
|
|
const navLabel = computed(() => isEn.value ? 'Page sections' : '页面章节')
|
|
|
|
const sections = computed<Section[]>(() => isEn.value
|
|
? [
|
|
{ id: 'end-to-end-web', label: 'Web Admin' },
|
|
{ id: 'end-to-end-gen', label: 'AI Generator' },
|
|
{ id: 'end-to-end-api', label: 'FastAPI Backend' },
|
|
{ id: 'end-to-end-app', label: 'Mobile' },
|
|
]
|
|
: [
|
|
{ id: 'end-to-end-web', label: 'Web 管理' },
|
|
{ id: 'end-to-end-gen', label: 'AI 生成' },
|
|
{ id: 'end-to-end-api', label: 'FastAPI 后端' },
|
|
{ id: 'end-to-end-app', label: '移动端' },
|
|
]
|
|
)
|
|
|
|
const activeSection = ref('')
|
|
const visible = ref(false)
|
|
let observer: IntersectionObserver | null = null
|
|
let scrollTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const scrollTo = (id: string) => {
|
|
const el = document.getElementById(id)
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
// Only show on homepage
|
|
if (typeof window !== 'undefined' && !window.location.pathname.includes('/guide/') && !window.location.pathname.includes('/about/')) {
|
|
// Delay showing to avoid flash
|
|
setTimeout(() => {
|
|
visible.value = true
|
|
}, 800)
|
|
}
|
|
|
|
observer = new IntersectionObserver(
|
|
(entries) => {
|
|
// Find the most visible section
|
|
let maxRatio = 0
|
|
let mostVisible = ''
|
|
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
|
|
maxRatio = entry.intersectionRatio
|
|
mostVisible = entry.target.id
|
|
}
|
|
}
|
|
|
|
if (mostVisible) {
|
|
activeSection.value = mostVisible
|
|
}
|
|
},
|
|
{
|
|
threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5],
|
|
rootMargin: '-80px 0px -40% 0px',
|
|
}
|
|
)
|
|
|
|
// Observe all section elements
|
|
setTimeout(() => {
|
|
for (const section of sections.value) {
|
|
const el = document.getElementById(section.id)
|
|
if (el) observer?.observe(el)
|
|
}
|
|
}, 200)
|
|
|
|
// Hide/show on scroll direction
|
|
let lastScrollY = 0
|
|
const handleScroll = () => {
|
|
if (scrollTimer) clearTimeout(scrollTimer)
|
|
scrollTimer = setTimeout(() => {
|
|
const currentY = window.scrollY
|
|
if (currentY < 200) {
|
|
visible.value = true
|
|
}
|
|
lastScrollY = currentY
|
|
}, 50)
|
|
}
|
|
|
|
document.addEventListener('scroll', handleScroll, { passive: true })
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (observer) observer.disconnect()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.section-nav {
|
|
position: fixed;
|
|
right: 1.5rem;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
z-index: var(--vp-z-index-sticky-cta); /* 70 — 与 sticky-cta 同级,语义分层清晰 */
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.6rem;
|
|
padding: 0.75rem 0.5rem;
|
|
background: var(--vp-home-card-bg);
|
|
border: 1px solid var(--vp-home-card-border);
|
|
border-radius: 9999px;
|
|
backdrop-filter: blur(16px);
|
|
-webkit-backdrop-filter: blur(16px);
|
|
transition: background 0.3s, border-color 0.3s;
|
|
}
|
|
|
|
.dark .section-nav {
|
|
background: rgba(0, 0, 0, 0.3);
|
|
border-color: rgba(255, 255, 255, 0.06);
|
|
}
|
|
|
|
.section-nav-dot {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
/* WCAG 2.5.5 44x44 触屏目标 */
|
|
min-width: 2.4rem;
|
|
min-height: 2.4rem;
|
|
padding: 0;
|
|
border: none;
|
|
background: none;
|
|
cursor: pointer;
|
|
transition: transform 0.3s ease;
|
|
}
|
|
|
|
.section-nav-dot:hover {
|
|
transform: scale(1.15);
|
|
}
|
|
|
|
.section-nav-dot:focus-visible {
|
|
outline: 2px solid var(--vp-brand-accent);
|
|
outline-offset: 2px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.section-nav-dot-inner {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: rgba(128, 128, 128, 0.35);
|
|
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.3s, border-color 0.3s, box-shadow 0.4s;
|
|
}
|
|
|
|
.section-nav-dot.active .section-nav-dot-inner {
|
|
width: 10px;
|
|
height: 10px;
|
|
background: var(--vp-brand-gradient);
|
|
box-shadow: 0 0 12px rgba(139, 92, 246, 0.6);
|
|
}
|
|
|
|
.section-nav-label {
|
|
position: absolute;
|
|
right: calc(100% + 0.75rem);
|
|
white-space: nowrap;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
color: var(--vp-home-text-1);
|
|
background: var(--vp-home-card-bg);
|
|
border: 1px solid var(--vp-home-card-border);
|
|
padding: 0.25rem 0.6rem;
|
|
border-radius: var(--vp-radius-sm);
|
|
backdrop-filter: blur(8px);
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: transform 0.25s, background 0.2s, border-color 0.2s, opacity 0.2s;
|
|
transform: translateX(8px);
|
|
}
|
|
|
|
.section-nav-dot:hover .section-nav-label {
|
|
opacity: 1;
|
|
transform: translateX(0);
|
|
}
|
|
|
|
/* ========================================================================
|
|
* Mobile — 横向 tab bar 替代方案(原 display: none,触屏用户失去导航)
|
|
* 设计:贴底悬浮、横向滚动、active 用品牌渐变
|
|
* ======================================================================== */
|
|
@media (max-width: 768px) {
|
|
.section-nav {
|
|
position: fixed;
|
|
top: auto;
|
|
bottom: 0.75rem;
|
|
left: 0.75rem;
|
|
right: 0.75rem;
|
|
transform: none;
|
|
flex-direction: row;
|
|
gap: 0.25rem;
|
|
padding: 0.4rem 0.5rem;
|
|
border-radius: var(--vp-radius-md);
|
|
overflow-x: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
scrollbar-width: none;
|
|
}
|
|
|
|
.section-nav::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.section-nav-dot {
|
|
flex: 0 0 auto;
|
|
min-width: 3.5rem;
|
|
min-height: 2.75rem; /* 44px WCAG 2.5.5 AAA */
|
|
padding: 0 0.5rem;
|
|
border-radius: var(--vp-radius-sm);
|
|
transition: background 0.2s, transform 0.2s;
|
|
}
|
|
|
|
.section-nav-dot:hover {
|
|
transform: none;
|
|
}
|
|
|
|
.section-nav-dot.active {
|
|
background: rgba(139, 92, 246, 0.18);
|
|
}
|
|
|
|
.section-nav-dot-inner {
|
|
display: none;
|
|
}
|
|
|
|
.section-nav-label {
|
|
position: static;
|
|
opacity: 1;
|
|
transform: none;
|
|
background: none;
|
|
border: none;
|
|
padding: 0;
|
|
backdrop-filter: none;
|
|
font-size: 0.78rem;
|
|
color: var(--vp-home-text-2);
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.section-nav-dot.active .section-nav-label {
|
|
color: var(--vp-home-text-1);
|
|
font-weight: 700;
|
|
}
|
|
}
|
|
</style>
|