AI-News/frontend/app/components/SidebarMini.vue
2025-12-04 10:04:21 +08:00

632 lines
18 KiB
Vue

<template>
<div class="sidebar-wrapper">
<!-- Expanded State -->
<aside
class="sidebar-nebula"
:class="{ 'is-hidden': isCollapsed }"
>
<!-- Logo Area -->
<div class="sidebar-logo">
<div class="logo-btn" data-trackid="home_header_logo_btn_click" @click="navigateTo('/')">
<div class="brand-logo"></div>
</div>
</div>
<!-- Main Navigation -->
<nav class="sidebar-menu main-menu">
<NuxtLink to="/" class="menu-item" active-class="active" title="首页广场">
<div class="icon-box">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>
</div>
<div class="active-indicator"></div>
</NuxtLink>
<NuxtLink to="/market" class="menu-item" active-class="active" title="资讯广场">
<div class="icon-box">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h15a1 1 0 0 1 1 1v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a1 1 0 0 1 1-1z"></path><path d="M8 8h7"></path><path d="M8 12h7"></path><path d="M8 16h4"></path></svg>
</div>
<div class="active-indicator"></div>
</NuxtLink>
<!-- <NuxtLink to="/apps" class="menu-item" active-class="active" title="AI 应用广场">
<div class="icon-box">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"></rect><rect x="14" y="3" width="7" height="7" rx="1.5"></rect><rect x="3" y="14" width="7" height="7" rx="1.5"></rect><rect x="14" y="14" width="7" height="7" rx="1.5"></rect></svg>
</div>
<div class="active-indicator"></div>
</NuxtLink> -->
<NuxtLink to="/community" class="menu-item" active-class="active" title="社区">
<div class="icon-box">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10h6"></path><path d="M7 14h4"></path><path d="M5 20l2.5-3H17a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H7a3 3 0 0 0-3 3v7a3 3 0 0 0 3 3"></path></svg>
</div>
<div class="active-indicator"></div>
</NuxtLink>
<NuxtLink to="/docs" class="menu-item" active-class="active" title="使用教程">
<div class="icon-box">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19a2 2 0 0 1 2-2h5v4H6a2 2 0 0 1-2-2z"></path><path d="M20 19a2 2 0 0 0-2-2h-5v4h5a2 2 0 0 0 2-2z"></path><path d="M6 3h5v10H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"></path><path d="M18 3h-5v10h5a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z"></path></svg>
</div>
<div class="active-indicator"></div>
</NuxtLink>
</nav>
<!-- Bottom Actions -->
<nav class="sidebar-menu bottom-menu">
<NuxtLink to="/favorites" class="menu-item" active-class="active" title="我的收藏">
<div class="icon-box">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 6.1a5 5 0 0 0-7.1 0L12 7.8l-1.7-1.7a5 5 0 0 0-7.1 7.1l1.7 1.7L12 21l7.1-7.1 1.7-1.7a5 5 0 0 0 0-7.1z"></path></svg>
</div>
<div class="active-indicator"></div>
</NuxtLink>
<ClientOnly>
<div class="auth-item" :key="isLoggedIn ? 'in' : 'out'">
<template v-if="isLoggedIn">
<div class="auth-actions">
<button type="button" class="avatar-btn" @click="goProfile" title="个人中心">
<div class="avatar-img">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
</div>
</button>
<button type="button" class="logout-btn" @click="handleLogout" title="退出登录">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
<polyline points="10 17 15 12 10 7"/>
<line x1="15" y1="12" x2="3" y2="12"/>
</svg>
</button>
</div>
</template>
<template v-else>
<button type="button" class="login-btn" @click="goLogin" title="登录">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path><polyline points="10 17 15 12 10 7"></polyline><line x1="15" y1="12" x2="3" y2="12"></line></svg>
</button>
</template>
</div>
</ClientOnly>
<!-- Collapse Button -->
<button type="button" class="collapse-btn" @click="toggleCollapse" title="收起导航">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
</nav>
</aside>
<!-- Collapsed State (Half-Dashboard Button) -->
<div
class="sidebar-collapsed-trigger"
:class="{ 'is-visible': isCollapsed }"
@click="toggleCollapse"
title="展开导航"
>
<div class="trigger-inner">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { navigateTo } from '#app'
import { useRoute } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import { useAuthToken } from '@/composables/useApi'
import { useToast } from '@/composables/useToast'
const { token } = useAuthToken()
const route = useRoute()
const isLoggedIn = computed(() => !!token.value)
const { logout } = useAuth()
const toast = useToast()
const isCollapsed = ref(true)
const userToggled = ref(false)
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
userToggled.value = true
}
const goLogin = () => navigateTo('/login')
const goProfile = () => navigateTo('/profile')
onMounted(() => {
checkScroll()
window.addEventListener('scroll', handleScroll)
window.addEventListener('keydown', handleKeydown)
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('keydown', handleKeydown)
})
watch(
() => route.path,
() => {
userToggled.value = false
checkScroll()
},
)
function handleScroll() {
if (userToggled.value) return
checkScroll()
}
function checkScroll() {
const scrollTop = window.scrollY || document.documentElement.scrollTop
// If at top (within 50px), collapse. Otherwise, expand.
isCollapsed.value = scrollTop < 50
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
isCollapsed.value = true
userToggled.value = true
}
}
async function handleLogout() {
try {
await logout()
} finally {
toast.success('已退出登录', '期待下次再见')
navigateTo('/login')
}
}
</script>
<style scoped>
/* --- Wrapper to manage layout space if needed --- */
.sidebar-wrapper {
position: fixed;
left: 0;
top: 0;
height: 100vh;
z-index: 999;
pointer-events: none; /* Let clicks pass through wrapper */
}
.sidebar-wrapper > * {
pointer-events: auto; /* Re-enable clicks on children */
}
/* --- Expanded Sidebar (Nebula Glass) --- */
.sidebar-nebula {
position: absolute;
left: 24px;
top: 134px;
bottom: 24px;
width: var(--sidebar-width-expanded, 80px);
background: rgba(255, 255, 255, 0);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: var(--sidebar-radius, 40px);
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 0;
/* Lighting & Shadows */
border: 1px solid var(--color-glass-border, rgba(255, 255, 255, 0.8));
box-shadow: var(--shadow-glass-floating);
transition: transform var(--duration-normal, 0.3s) var(--ease-out-back), opacity 0.2s ease;
transform-origin: left center;
}
.sidebar-nebula.is-hidden {
transform: translateX(-150%) scale(0.9);
opacity: 0;
pointer-events: none;
}
/* Optional Noise Texture */
.sidebar-nebula::before {
content: "";
position: absolute;
inset: 0;
border-radius: var(--sidebar-radius, 40px);
background: radial-gradient(circle at top left, rgba(255,255,255,0.4), transparent 70%);
pointer-events: none;
z-index: -1;
}
/* Logo Area */
.sidebar-logo {
margin-bottom: 36px;
flex-shrink: 0;
}
.logo-btn {
width: 48px;
height: 48px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
border: 1px solid rgba(255, 255, 255, 0.6);
}
.logo-btn:hover {
transform: scale(1.05);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12);
}
.brand-logo {
width: 32px;
height: 32px;
background: url("../assets/logoleft.png") no-repeat center/contain;
}
/* Menu Layout */
.sidebar-menu {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: 100%;
}
.main-menu {
flex: 1;
}
.bottom-menu {
margin-top: auto;
gap: 16px;
padding-top: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.3);
width: 60%;
}
.bottom-menu .menu-item,
.bottom-menu .auth-item,
.collapse-btn {
width: 100%;
display: flex;
justify-content: center;
}
/* Menu Item Styles */
.menu-item {
position: relative;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
color: var(--color-text-sub, #64748b);
transition: all 0.3s var(--ease-smooth);
}
.menu-item::after {
content: attr(title);
position: absolute;
left: 64px;
white-space: nowrap;
background: #0b1221;
color: #e5e7eb;
padding: 8px 12px;
border-radius: 12px;
font-size: 12px;
letter-spacing: 0.2px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.25);
opacity: 0;
pointer-events: none;
transform: translateY(4px);
transition: opacity 0.18s ease, transform 0.18s ease;
z-index: 10;
}
.menu-item:hover::after {
opacity: 1;
transform: translateY(0);
backdrop-filter: blur(6px);
}
/* Tooltip for auth buttons */
.avatar-btn::after,
.login-btn::after,
.logout-btn::after {
content: attr(title);
position: absolute;
left: 62px;
white-space: nowrap;
background: #0b1221;
color: #e5e7eb;
padding: 8px 12px;
border-radius: 12px;
font-size: 12px;
letter-spacing: 0.2px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.25);
opacity: 0;
pointer-events: none;
transform: translateY(4px);
transition: opacity 0.18s ease, transform 0.18s ease;
z-index: 10;
}
.avatar-btn:hover::after,
.login-btn:hover::after,
.logout-btn:hover::after {
opacity: 1;
transform: translateY(0);
backdrop-filter: blur(6px);
}
.icon-box {
width: 52px;
height: 52px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
position: relative;
z-index: 2;
background: transparent;
}
.icon-box svg {
width: 24px;
height: 24px;
stroke-width: 1.5px;
transition: all 0.3s ease;
}
/* Hover State */
.menu-item:hover .icon-box {
background: var(--color-glass-hover, rgba(255, 255, 255, 0.85));
border-radius: 18px;
box-shadow: 0 0 12px rgba(255, 255, 255, 0.6);
color: var(--color-text-main, #1e293b);
transform: translateY(-2px);
}
/* Active State */
.menu-item.active .icon-box {
background: linear-gradient(135deg, var(--color-primary-start), var(--color-primary-end));
border-radius: 20px;
color: var(--color-text-active, #fff);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.3),
0 8px 20px -4px rgba(99, 102, 241, 0.45);
}
/* Right Side Indicator (The Beam) */
.active-indicator {
position: absolute;
right: -12px;
top: 50%;
transform: translateY(-50%) scaleY(0);
width: 3px;
height: 24px;
background: var(--color-accent-beam, #a855f7);
border-radius: 2px;
box-shadow: var(--shadow-neon-glow);
transition: transform 0.3s var(--ease-out-back), opacity 0.3s ease;
opacity: 0;
}
.menu-item.active .active-indicator {
transform: translateY(-50%) scaleY(1);
opacity: 1;
}
/* Auth / Avatar */
.auth-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.avatar-btn,
.login-btn,
.collapse-btn {
width: 48px;
height: 48px;
border-radius: 18px;
border: none;
background: rgba(255, 255, 255, 0.55);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-sub, #64748b);
transition: all 0.3s ease;
}
.avatar-btn:hover,
.login-btn:hover,
.collapse-btn:hover {
background: #ffffff;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.12);
color: var(--color-primary-end, #3b82f6);
transform: scale(1.05);
}
.avatar-img svg,
.login-btn svg,
.collapse-btn svg {
width: 22px;
height: 22px;
}
.auth-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.logout-btn {
width: 48px;
height: 48px;
border-radius: 16px;
border: 1px solid rgba(229, 231, 235, 0.9);
background: #fff;
cursor: pointer;
font-size: 12px;
color: #ef4444;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.logout-btn:hover {
background: #fff7f7;
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.18);
transform: translateY(-1px);
}
.logout-btn svg {
width: 22px;
height: 22px;
stroke: #ef4444;
}
/* --- Collapsed Trigger (Half-Dashboard) --- */
.sidebar-collapsed-trigger {
position: absolute;
left: 0;
top: 90%;
transform: translateY(-50%) translateX(-100%);
width: 70px;
height: 70px;
border-radius: 0 36px 36px 0; /* Half circle on right */
background: linear-gradient(145deg, #c8d8ff, #9b8cff 45%, #f5d3ff);
box-shadow: 0 14px 36px rgba(142, 123, 255, 0.35), 0 0 0 6px rgba(255, 255, 255, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding-left: 8px;
cursor: pointer;
transition: transform var(--duration-normal) var(--ease-out-back), box-shadow 0.2s ease;
z-index: 1000;
opacity: 0;
}
.sidebar-collapsed-trigger.is-visible {
transform: translateY(-50%) translateX(0);
opacity: 1;
}
.sidebar-collapsed-trigger:hover {
transform: translateY(-50%) translateX(6px) scale(1.02); /* Slight peek */
box-shadow: 0 18px 40px rgba(142, 123, 255, 0.45), 0 0 0 8px rgba(255, 255, 255, 0.52);
}
.sidebar-collapsed-trigger:active {
transform: translateY(-50%) translateX(2px) scale(0.99);
}
.trigger-inner {
width: 40px;
height: 40px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.9);
display: grid;
place-items: center;
color: #0f172a;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.55), 0 8px 18px rgba(87, 71, 199, 0.35);
}
.trigger-inner svg {
width: 24px;
height: 24px;
}
/* ===== Mobile Bottom Nav Adaptation ===== */
@media (max-width: 640px) {
.sidebar-wrapper {
position: absolute; /* Reset fixed */
height: 100%;
width: 100%;
z-index: auto;
}
.sidebar-nebula {
position: static; /* Reset absolute */
width: 100%;
height: 100%;
border-radius: 0;
border: none;
box-shadow: none;
background: transparent;
flex-direction: row;
padding: 0 12px;
justify-content: space-around;
align-items: center;
transform: none !important; /* Disable collapse transform */
opacity: 1 !important;
}
.sidebar-nebula::before {
display: none;
}
.sidebar-logo,
.collapse-btn,
.sidebar-collapsed-trigger {
display: none !important;
}
.sidebar-menu {
flex-direction: row;
width: auto;
gap: 4px;
}
.main-menu {
flex: 1;
justify-content: space-around;
}
.bottom-menu {
margin-top: 0;
padding-top: 0;
border-top: none;
width: auto;
gap: 4px;
}
.menu-item {
width: 44px;
height: 44px;
}
.menu-item::after {
display: none; /* No tooltips on mobile */
}
.icon-box {
width: 40px;
height: 40px;
}
.auth-item {
flex-direction: row;
}
.auth-actions {
flex-direction: row;
}
.avatar-btn, .login-btn, .logout-btn {
width: 40px;
height: 40px;
}
}
</style>