码桶
发现社区成员的开源项目
AppDownloadFab.vue6 KB
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { getAppRelease } from '@/api/public'
const STORAGE_KEY = 'app_release_version'
const NEW_BADGE_DURATION = 2500
const VERSION_BADGE_DURATION = 3000
const visible = ref(false)
const showNewBadge = ref(false)
const rotateBadge = ref(false)
const downloadUrl = ref('')
const currentVersion = ref('')
let badgeTimer = 0
const badgeText = computed(() => {
if (showNewBadge.value) return 'NEW'
if (!currentVersion.value) return ''
return currentVersion.value.toLowerCase().startsWith('v')
? currentVersion.value
: `v${currentVersion.value}`
})
function compareVersions(a: string, b: string): number {
const pa = a.replace(/^v/i, '').split('.').map(part => parseInt(part, 10) || 0)
const pb = b.replace(/^v/i, '').split('.').map(part => parseInt(part, 10) || 0)
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const na = pa[i] || 0
const nb = pb[i] || 0
if (na > nb) return 1
if (na < nb) return -1
}
return 0
}
function isMobileUA(): boolean {
return /Android/i.test(navigator.userAgent)
}
function stopBadgeRotation() {
if (badgeTimer) window.clearTimeout(badgeTimer)
badgeTimer = 0
}
function scheduleBadgeSwap() {
stopBadgeRotation()
if (!rotateBadge.value) return
badgeTimer = window.setTimeout(() => {
showNewBadge.value = !showNewBadge.value
scheduleBadgeSwap()
}, showNewBadge.value ? NEW_BADGE_DURATION : VERSION_BADGE_DURATION)
}
onMounted(async () => {
if (!isMobileUA()) return
try {
const { appRelease } = await getAppRelease()
if (appRelease && appRelease.downloadUrl) {
downloadUrl.value = appRelease.downloadUrl
currentVersion.value = appRelease.version
visible.value = true
const stored = localStorage.getItem(STORAGE_KEY)
if (!stored || compareVersions(appRelease.version, stored) > 0) {
showNewBadge.value = true
rotateBadge.value = true
scheduleBadgeSwap()
}
}
} catch {
// silent
}
})
onBeforeUnmount(() => {
stopBadgeRotation()
})
function handleClick() {
if (downloadUrl.value) {
if (currentVersion.value) {
localStorage.setItem(STORAGE_KEY, currentVersion.value)
rotateBadge.value = false
stopBadgeRotation()
showNewBadge.value = false
}
window.open(downloadUrl.value, '_blank')
}
}
</script>
<template>
<Transition name="fab-enter">
<div v-if="visible" class="app-download-fab" @click="handleClick">
<div v-if="badgeText" class="fab-badge">
<Transition name="badge-swap" mode="out-in">
<span :key="badgeText" class="fab-badge-text">{{ badgeText }}</span>
</Transition>
</div>
<div class="fab-content">
<svg class="fab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span class="fab-label">APP</span>
</div>
</div>
</Transition>
</template>
<style scoped>
.app-download-fab {
position: fixed;
bottom: 24px;
right: 20px;
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--accent-primary);
color: var(--accent-on-primary);
cursor: pointer;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease;
animation: fab-pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.dark .app-download-fab {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}
.app-download-fab:hover {
transform: translateY(-2px) scale(1.02);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
}
.app-download-fab:active {
transform: scale(0.95);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.fab-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
.fab-icon {
width: 20px;
height: 20px;
}
.fab-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
line-height: 1;
}
.fab-badge {
position: absolute;
top: -4px;
right: -4px;
width: 42px;
height: 16px;
box-sizing: border-box;
display: grid;
place-items: center;
overflow: hidden;
background: var(--color-danger);
color: #fff;
font-size: 8px;
font-weight: 700;
padding: 2px 5px;
border-radius: 6px;
letter-spacing: 0.03em;
line-height: 1;
box-shadow: 0 2px 6px rgba(238, 0, 0, 0.3);
animation: badge-bounce 2s ease-in-out infinite;
}
.fab-badge-text {
display: block;
white-space: nowrap;
}
.badge-swap-enter-active,
.badge-swap-leave-active {
transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.16, 1, 0.3, 1), filter 0.22s ease;
}
.badge-swap-enter-from {
opacity: 0;
transform: translateY(-5px) scale(0.9);
filter: blur(2px);
}
.badge-swap-leave-to {
opacity: 0;
transform: translateY(5px) scale(0.9);
filter: blur(2px);
}
@keyframes fab-pulse {
0%, 100% {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
50% {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25), 0 0 0 6px rgba(0, 0, 0, 0.06);
}
}
.dark .app-download-fab {
animation: fab-pulse-dark 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes fab-pulse-dark {
0%, 100% {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}
50% {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 0 6px rgba(255, 255, 255, 0.06);
}
}
@keyframes badge-bounce {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.fab-enter-enter-active {
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.fab-enter-leave-active {
transition: all 0.2s ease;
}
.fab-enter-enter-from {
opacity: 0;
transform: scale(0.5) translateY(20px);
}
.fab-enter-leave-to {
opacity: 0;
transform: scale(0.8);
}
@media (prefers-reduced-motion: reduce) {
.badge-swap-enter-active,
.badge-swap-leave-active {
transition: none;
}
}
</style>