Files
SearcjGal-frontend/src/components/FloatingButtons.vue
AdingApkgg c2fdc58f21 Update package dependencies and enhance component styles for consistency
- Added `vue-virtual-scroller` dependency to package.json for improved rendering performance.
- Updated styles across multiple components to implement a consistent translucent effect, enhancing visual coherence and performance.
- Adjusted background colors, borders, and shadows to align with the new design approach, ensuring a modern and clean aesthetic.
2025-12-26 22:20:56 +08:00

530 lines
15 KiB
Vue
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.
<template>
<!-- 浮动按钮组 -->
<div class="floating-buttons fixed bottom-4 sm:bottom-6 right-4 sm:right-6 flex flex-col gap-2 sm:gap-3 z-40">
<!-- 回到顶部按钮 - 显示滚动进度 -->
<button
v-show="showScrollToTop"
v-ripple
aria-label="回到顶部"
class="fab-button scroll-top-btn"
@click="handleScrollToTop"
@mouseenter="isHoveringScrollTop = true"
@mouseleave="isHoveringScrollTop = false"
>
<ArrowUp v-if="isHoveringScrollTop || Math.round(scrollProgress) >= 100" :size="20" />
<span v-else class="text-sm font-bold">{{ Math.round(scrollProgress) }}%</span>
</button>
<!-- 站点导航按钮 -->
<button
v-show="searchStore.hasResults"
v-ripple
:aria-label="showPlatformNav ? '关闭站点导航' : '打开站点导航'"
class="fab-button nav-btn"
:class="{ 'nav-open': showPlatformNav }"
@click="handleTogglePlatformNav"
>
<component :is="showPlatformNav ? X : Grid3x3" :size="20" />
</button>
<!-- 作品介绍按钮 -->
<button
v-show="searchStore.vndbInfo"
v-ripple
:aria-label="uiStore.isVndbPanelOpen ? '关闭作品介绍' : '打开作品介绍'"
class="fab-button vndb-btn"
:class="{ 'vndb-open': uiStore.isVndbPanelOpen }"
@click="handleToggleVndbPanel"
>
<component :is="uiStore.isVndbPanelOpen ? X : BookOpen" :size="20" />
</button>
<!-- 评论按钮 -->
<button
v-ripple
:aria-label="uiStore.isCommentsModalOpen ? '关闭评论' : '打开评论'"
class="fab-button comments-btn"
:class="{ 'comments-open': uiStore.isCommentsModalOpen }"
@click="handleToggleComments"
>
<component :is="uiStore.isCommentsModalOpen ? X : MessageSquare" :size="20" />
</button>
<!-- 搜索历史按钮 -->
<button
v-ripple
:aria-label="uiStore.isHistoryModalOpen ? '关闭搜索历史' : '打开搜索历史'"
class="fab-button history-btn"
:class="{ 'history-open': uiStore.isHistoryModalOpen }"
@click="handleToggleHistory"
>
<component :is="uiStore.isHistoryModalOpen ? X : History" :size="20" />
</button>
</div>
<!-- 站点导航面板 - 独立于按钮组避免 transform 影响 fixed 定位 -->
<Teleport to="body">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 scale-90 translate-y-2"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-90 translate-y-2"
>
<div
v-if="showPlatformNav && searchStore.hasResults"
class="nav-panel fixed z-50 flex flex-col
bottom-20 right-4 w-72 max-h-[60vh]
rounded-2xl shadow-2xl shadow-black/20"
>
<!-- 标题栏 -->
<div class="nav-header flex items-center justify-between px-4 py-3 rounded-t-2xl">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-[#ff1493] to-[#d946ef] flex items-center justify-center shadow-md shadow-pink-500/30">
<Grid3x3 :size="16" class="text-white" />
</div>
<div>
<h3 class="font-bold text-sm text-gray-800 dark:text-white">站点导航</h3>
<p class="text-xs text-gray-500 dark:text-slate-400">{{ totalResults }} 个结果</p>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400 dark:text-slate-500">
{{ searchStore.platformResults.size }} 站点
</span>
<button
class="w-7 h-7 rounded-full flex items-center justify-center text-gray-400 hover:text-[#ff1493] hover:bg-pink-50 dark:hover:bg-pink-900/30 transition-colors"
@click="togglePlatformNav(true)"
>
<X :size="16" />
</button>
</div>
</div>
<!-- 平台列表 -->
<div class="flex-1 overflow-y-auto custom-scrollbar px-2 py-2">
<button
v-for="([platformName, platformData], index) in searchStore.platformResults"
:key="platformName"
v-ripple
class="nav-item w-full px-3 py-2.5 mb-1 last:mb-0 flex items-center gap-3 rounded-xl transition-all duration-200"
:style="{ animationDelay: `${index * 30}ms` }"
@click="handleScrollToPlatform(platformName)"
>
<!-- 平台图标 -->
<div
class="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 shadow-md"
:class="getPlatformIconBg(platformData.color)"
>
<component
:is="getPlatformIcon(platformData.color)"
:size="14"
class="text-white"
/>
</div>
<!-- 平台名称 -->
<span
v-text-scroll
class="flex-1 text-sm font-medium text-gray-700 dark:text-slate-200 text-left"
>
{{ platformName }}
</span>
<!-- 结果数量 -->
<span class="count-badge text-xs px-2 py-1">
{{ platformData.items.length }}
</span>
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useSearchStore } from '@/stores/search'
import { useUIStore } from '@/stores/ui'
import { playTap, playButton, playTransitionUp, playTransitionDown, playSwipe } from '@/composables/useSound'
import { throttle } from '@/composables/useDebounce'
import { ArrowUp, X, Grid3x3, BookOpen, MessageSquare, History, Star, Circle, DollarSign, XCircle } from 'lucide-vue-next'
import type { FunctionalComponent } from 'vue'
const searchStore = useSearchStore()
const uiStore = useUIStore()
const showScrollToTop = ref(false)
const showPlatformNav = ref(false)
const scrollProgress = ref(0)
const isHoveringScrollTop = ref(false)
// 计算总结果数
const totalResults = computed(() => {
let total = 0
searchStore.platformResults.forEach((data) => {
total += data.items.length
})
return total
})
// 根据颜色获取对应的图标组件
function getPlatformIcon(color: string): FunctionalComponent {
const icons: Record<string, FunctionalComponent> = {
lime: Star,
white: Circle,
gold: DollarSign,
red: XCircle,
}
return icons[color] || Circle
}
// 根据颜色获取图标背景类
function getPlatformIconBg(color: string): string {
const bgs: Record<string, string> = {
lime: 'bg-gradient-to-br from-lime-500 to-emerald-600 shadow-lime-500/30',
white: 'bg-gradient-to-br from-gray-400 to-gray-500 shadow-gray-400/30',
gold: 'bg-gradient-to-br from-yellow-500 to-amber-600 shadow-yellow-500/30',
red: 'bg-gradient-to-br from-red-500 to-rose-600 shadow-red-500/30',
}
return bgs[color] || 'bg-gradient-to-br from-gray-400 to-gray-500'
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function toggleComments() {
uiStore.isCommentsModalOpen = !uiStore.isCommentsModalOpen
}
function toggleVndbPanel() {
uiStore.isVndbPanelOpen = !uiStore.isVndbPanelOpen
}
function toggleHistory() {
uiStore.isHistoryModalOpen = !uiStore.isHistoryModalOpen
}
function togglePlatformNav(withSound = false) {
const isClosing = showPlatformNav.value
showPlatformNav.value = !showPlatformNav.value
// 如果是通过面板内的关闭按钮调用,需要播放音效
if (withSound) {
if (isClosing) {
playTransitionDown()
} else {
playTransitionUp()
}
}
}
// 带音效的操作函数
function handleScrollToTop() {
playSwipe()
scrollToTop()
}
function handleToggleComments() {
playButton()
toggleComments()
}
function handleToggleVndbPanel() {
playButton()
toggleVndbPanel()
}
function handleToggleHistory() {
playButton()
toggleHistory()
}
function handleTogglePlatformNav() {
// 根据当前状态播放不同音效
if (showPlatformNav.value) {
playTransitionDown()
} else {
playTransitionUp()
}
showPlatformNav.value = !showPlatformNav.value
}
function handleScrollToPlatform(platformName: string) {
playTap()
scrollToPlatform(platformName)
}
function scrollToPlatform(platformName: string) {
const platformElements = document.querySelectorAll('[data-platform]')
const targetElement = Array.from(platformElements).find(
el => el.getAttribute('data-platform') === platformName,
) as HTMLElement
if (targetElement) {
const yOffset = -80
const y = targetElement.getBoundingClientRect().top + window.pageYOffset + yOffset
window.scrollTo({ top: y, behavior: 'smooth' })
// 滚动后关闭导航
playTransitionDown()
showPlatformNav.value = false
}
}
function handleScroll() {
const scrollTop = window.scrollY
const docHeight = document.documentElement.scrollHeight - window.innerHeight
showScrollToTop.value = scrollTop > 200
scrollProgress.value = docHeight > 0 ? Math.min((scrollTop / docHeight) * 100, 100) : 0
}
// 节流滚动处理 - 每 100ms 最多触发一次
const throttledHandleScroll = throttle(handleScroll, 100)
onMounted(() => {
window.addEventListener('scroll', throttledHandleScroll, { passive: true })
handleScroll()
})
onUnmounted(() => {
window.removeEventListener('scroll', throttledHandleScroll)
})
</script>
<style>
.fab-button {
width: 44px;
height: 44px;
border-radius: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
position: relative;
overflow: hidden;
/* 半透明效果 */
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-button, 0.75));
box-shadow: var(--shadow-md, 0 4px 12px rgba(0, 0, 0, 0.12));
border: var(--border-thin, 1px) solid rgba(var(--color-primary, 255, 20, 147), var(--opacity-border, 0.15));
/* 性能优化 */
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 2.2);
transform: translate3d(0, 0, 0);
}
.dark .fab-button {
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-button-dark, 0.75));
border-color: rgba(var(--color-primary-light, 255, 105, 180), var(--opacity-border-dark, 0.2));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
@media (min-width: 640px) {
.fab-button {
width: 52px;
height: 52px;
border-radius: 22px;
font-size: 22px;
}
}
@media (min-width: 1024px) {
.fab-button {
width: 56px;
height: 56px;
border-radius: 24px;
font-size: 24px;
}
}
.fab-button:hover {
transform: translate3d(0, -4px, 0) scale(1.08);
box-shadow:
0 16px 48px rgba(255, 20, 147, 0.35),
0 8px 24px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
border-color: rgba(255, 255, 255, 0.5);
}
.dark .fab-button:hover {
box-shadow:
0 16px 48px rgba(255, 105, 180, 0.4),
0 8px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.25);
}
.fab-button:active {
transform: translate3d(0, -2px, 0) scale(1.02);
}
/* 各按钮特定颜色 - 艳粉主题 */
.scroll-top-btn {
background: linear-gradient(135deg, rgb(236, 72, 153), rgb(219, 39, 119));
color: white;
}
.comments-btn {
background: linear-gradient(135deg, #ff1493, #c71585);
color: white;
}
.comments-btn.comments-open {
background: linear-gradient(135deg, rgb(255, 105, 180), rgb(199, 21, 133));
color: white;
border-color: rgba(255, 105, 180, 0.5);
}
.history-btn {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
}
.history-btn.history-open {
background: linear-gradient(135deg, rgb(251, 191, 36), rgb(245, 158, 11));
color: white;
border-color: rgba(251, 191, 36, 0.5);
}
.vndb-btn {
background: linear-gradient(135deg, #d946ef, #c026d3);
color: white;
}
.vndb-btn.vndb-open {
background: linear-gradient(135deg, rgb(232, 121, 249), rgb(217, 70, 239));
color: white;
border-color: rgba(232, 121, 249, 0.5);
}
.nav-btn {
background: linear-gradient(135deg, rgb(255, 20, 147), rgb(217, 70, 239));
color: white;
}
.nav-btn.nav-open {
background: linear-gradient(135deg, rgb(255, 105, 180), rgb(232, 121, 249));
color: white;
border-color: rgba(255, 105, 180, 0.5);
}
.fab-button i {
transition: transform 0.3s ease;
}
.fab-button:hover i {
transform: scale(1.1);
}
/* 自定义滚动条 - 艳粉主题 */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(255, 20, 147, 0.5), rgba(217, 70, 239, 0.5));
border-radius: 2px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(255, 20, 147, 0.7), rgba(217, 70, 239, 0.7));
}
/* ============================================
站点导航面板样式 - WWDC 2025 液态玻璃
============================================ */
/* 站点导航面板 - 液态玻璃效果 */
.nav-panel {
/* 不设置 position使用模板中的 fixed */
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-panel, 0.85));
border: var(--border-thin, 1px) solid rgba(var(--color-primary, 255, 20, 147), var(--opacity-border, 0.15));
box-shadow: var(--shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.15));
overflow: hidden;
}
/* 站点导航面板 - 暗色模式 */
.dark .nav-panel {
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-panel-dark, 0.88));
border-color: rgba(var(--color-primary-light, 255, 105, 180), var(--opacity-border-dark, 0.2));
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
/* 标题栏 */
.nav-header {
background: linear-gradient(135deg, rgba(255, 20, 147, 0.08), rgba(217, 70, 239, 0.05));
border-bottom: 1px solid rgba(255, 20, 147, 0.15);
}
.dark .nav-header {
background: linear-gradient(135deg, rgba(255, 20, 147, 0.12), rgba(217, 70, 239, 0.08));
border-bottom: 1px solid rgba(255, 105, 180, 0.15);
}
/* 导航项 - GPU 加速动画 */
.nav-item {
background: transparent;
animation: navItemSlideIn 0.3s ease-out both;
/* 强制 GPU 层 */
transform: translate3d(0, 0, 0);
transition: transform 0.2s ease-out, background 0.2s ease-out;
}
.nav-item:hover {
background: linear-gradient(135deg, rgba(255, 20, 147, 0.08), rgba(217, 70, 239, 0.05));
transform: translate3d(4px, 0, 0);
}
.nav-item:active {
transform: translate3d(2px, 0, 0) scale(0.98);
}
.dark .nav-item:hover {
background: linear-gradient(135deg, rgba(255, 20, 147, 0.15), rgba(217, 70, 239, 0.1));
}
@keyframes navItemSlideIn {
from {
opacity: 0;
transform: translate3d(-10px, 0, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
/* 数量徽章 */
.count-badge {
min-width: 1.5rem;
height: 1.5rem;
padding: 0 0.375rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #ff1493, #d946ef);
border-radius: 0.5rem;
box-shadow: 0 2px 6px rgba(255, 20, 147, 0.3);
}
.dark .count-badge {
background: linear-gradient(135deg, #ff69b4, #e879f9);
box-shadow: 0 2px 8px rgba(255, 105, 180, 0.4);
}
/* 底部栏 */
</style>