clouduse / frontend /src /views /TreeView.vue
Jerry20062016's picture
Link hero badge and simplify pagination label
ec27ba3
<script setup lang="ts">
import { ref, onMounted, computed, onBeforeUnmount } from 'vue'
import { useWishStore } from '@/stores/wish'
import { useAuthStore } from '@/stores/auth'
import WishPanel from '@/components/WishPanel.vue'
import UndoBanner from '@/components/UndoBanner.vue'
const mobilePanelOpen = ref(false)
const filterOpen = ref(false)
const isMobileViewport = ref(false)
const MOBILE_TREE_LIMIT = 9
const DESKTOP_TREE_LIMIT = 30
const CARD_COLORS = ['card-rose', 'card-amber', 'card-mint', 'card-sky', 'card-violet', 'card-peach'] as const
const STATUS_ICONS: Record<string, string> = {
active: '\u{1F331}',
expired: '\u{1F31F}',
fulfilled: '\u{2728}',
}
const STATUS_LABELS: Record<string, string> = {
active: '\u{1F331} 愿望实现中',
expired: '\u{1F31F} 已过期',
fulfilled: '\u{2728} 已实现',
}
const FILTER_OPTIONS = [
{ value: 'all', label: '全部' },
{ value: 'active', label: '未实现' },
{ value: 'fulfilled', label: '已实现' },
{ value: 'expired', label: '已过期' },
] as const
const wishStore = useWishStore()
const authStore = useAuthStore()
// Popover state
const hoveredWishId = ref<number | null>(null)
const popoverPos = ref({ x: 0, y: 0 })
let hoverTimer: ReturnType<typeof setTimeout> | null = null
const popoverWish = computed(() => {
if (!hoveredWishId.value) return null
return wishStore.treeWishes.find(w => w.id === hoveredWishId.value) ?? null
})
const isOwner = computed(() => {
if (!popoverWish.value || !authStore.user) return false
return popoverWish.value.user_id === authStore.user.id
})
const canFulfill = computed(() => {
return isOwner.value && popoverWish.value?.is_fulfilled !== 1
})
function formatDateTime(ts: number) {
const d = new Date(ts * 1000)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} (UTC+8)`
}
function onCardEnter(wish: any, event: MouseEvent) {
if (isMobileViewport.value) return
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null }
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
popoverPos.value = {
x: rect.left + rect.width / 2,
y: rect.bottom + 8,
}
hoveredWishId.value = wish.id
}
function onCardLeave() {
if (isMobileViewport.value) return
if (hoverTimer) clearTimeout(hoverTimer)
hoverTimer = setTimeout(() => { hoveredWishId.value = null }, 200)
}
function onPopoverEnter() {
if (isMobileViewport.value) return
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null }
}
function onPopoverLeave() {
if (isMobileViewport.value) return
if (hoverTimer) clearTimeout(hoverTimer)
hoverTimer = setTimeout(() => { hoveredWishId.value = null }, 200)
}
function updateViewportMode() {
const nextIsMobile = window.innerWidth <= 768
const nextLimit = nextIsMobile ? MOBILE_TREE_LIMIT : DESKTOP_TREE_LIMIT
const limitChanged = wishStore.treeLimit !== nextLimit
isMobileViewport.value = nextIsMobile
wishStore.setTreeLimit(nextLimit)
if (limitChanged && wishStore.treeWishes.length > 0) {
wishStore.fetchTreeWishes(1, wishStore.treeFilter)
}
}
function openWishDetail(wish: any, event: MouseEvent) {
if (!isMobileViewport.value) {
onCardEnter(wish, event)
return
}
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null }
hoveredWishId.value = wish.id
}
function closeWishDetail() {
hoveredWishId.value = null
}
async function fulfillWish() {
if (!popoverWish.value) return
await wishStore.fulfillWish(popoverWish.value.id)
await wishStore.fetchTreeWishes()
await wishStore.fetchMyWishes()
}
async function deleteWish() {
if (!popoverWish.value) return
await wishStore.deleteWish(popoverWish.value.id)
hoveredWishId.value = null
await wishStore.fetchTreeWishes()
await wishStore.fetchMyWishes()
await wishStore.fetchDeletedWishes()
}
const currentFilterLabel = computed(() => {
const opt = FILTER_OPTIONS.find(o => o.value === wishStore.treeFilter)
return opt ? opt.label : '全部'
})
async function handleUndo(wishId: number) {
try {
await wishStore.restoreWish(wishId)
await wishStore.fetchTreeWishes()
await wishStore.fetchMyWishes()
await wishStore.fetchDeletedWishes()
} catch {
// restore failed — banner stays
}
}
async function refreshDeletedState() {
if (authStore.isAuthenticated) {
await wishStore.fetchDeletedWishes()
}
}
function setFilter(value: string) {
filterOpen.value = false
wishStore.fetchTreeWishes(1, value)
}
function closeFilter(e: MouseEvent) {
if (filterOpen.value && !(e.target as HTMLElement).closest('.filter-dropdown')) {
filterOpen.value = false
}
}
function prevPage() {
if (wishStore.treePage > 1) wishStore.fetchTreeWishes(wishStore.treePage - 1, wishStore.treeFilter)
}
function nextPage() {
if (wishStore.treePage < wishStore.treeTotalPages) wishStore.fetchTreeWishes(wishStore.treePage + 1, wishStore.treeFilter)
}
onMounted(async () => {
updateViewportMode()
wishStore.fetchTreeWishes()
await refreshDeletedState()
document.addEventListener('click', closeFilter)
window.addEventListener('resize', updateViewportMode)
})
onBeforeUnmount(() => {
document.removeEventListener('click', closeFilter)
window.removeEventListener('resize', updateViewportMode)
if (hoverTimer) clearTimeout(hoverTimer)
})
</script>
<template>
<div class="min-h-screen bg-page-bg">
<!-- Hero -->
<section class="px-6 pt-7 pb-4 md:px-10">
<a
href="https://jerry20062016-clouduse.hf.space/"
class="inline-flex items-center px-3.5 py-2 rounded-full bg-gold/12 text-gold-deep text-xs font-bold tracking-widest uppercase transition-colors hover:bg-gold/18 focus:outline-none focus:ring-2 focus:ring-gold/30"
>
Wish Tree
</a>
<h1 class="mt-3.5 mb-2.5 text-[clamp(2.3rem,4vw,4.4rem)] leading-[0.96] tracking-tight font-semibold text-text-main">
把愿望挂上树,让它在时间里慢慢发芽。
</h1>
<p class="max-w-[760px] text-text-soft leading-[1.72]">
在这里许下你的心愿。每一个愿望都是一颗种子,有人为你浇灌,有人为你守护。愿所有美好的期许,都能在时光中生根。
</p>
</section>
<!-- Two-column layout -->
<div class="flex gap-4 px-6 md:px-10">
<!-- Sidebar panel (desktop) -->
<div class="hidden lg:block w-[340px] flex-shrink-0">
<div class="sticky top-4 rounded-xl bg-white/96 border border-gold/18">
<WishPanel />
</div>
</div>
<!-- Mobile panel toggle -->
<button
type="button"
class="lg:hidden fixed bottom-4 right-4 z-50 w-12 h-12 rounded-full bg-gold text-white shadow-lg flex items-center justify-center text-xl"
@click="mobilePanelOpen = !mobilePanelOpen"
>
{{ mobilePanelOpen ? '\u2715' : '\u{1F331}' }}
</button>
<!-- Mobile panel overlay -->
<Transition name="slide">
<div
v-if="mobilePanelOpen"
class="lg:hidden fixed inset-0 z-40 bg-black/30"
@click.self="mobilePanelOpen = false"
>
<div class="absolute right-0 top-0 bottom-0 w-[340px] max-w-[90vw] bg-page-bg overflow-y-auto shadow-2xl">
<div class="p-4">
<button
type="button"
class="ml-auto block text-text-muted hover:text-text-soft text-lg"
@click="mobilePanelOpen = false"
>
Close
</button>
</div>
<WishPanel />
</div>
</div>
</Transition>
<!-- Tree area -->
<div class="relative flex-1 min-w-0">
<!-- Undo Banner -->
<UndoBanner v-if="wishStore.deletedWishes.length > 0" @undo="handleUndo" />
<!-- Filter -->
<div class="mb-3 relative filter-dropdown">
<button
type="button"
class="inline-flex items-center gap-2 bg-panel-bg rounded-lg px-4 py-2 border border-gold/10 text-sm hover:bg-white/60 transition-colors"
@click.stop="filterOpen = !filterOpen"
>
<span class="text-text-soft font-medium">{{ currentFilterLabel }}</span>
<svg class="w-4 h-4 text-text-muted transition-transform" :class="{ 'rotate-180': filterOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Transition name="fade">
<div
v-if="filterOpen"
class="absolute top-full left-0 mt-1 w-36 bg-white rounded-lg border border-gold/10 shadow-lg z-20 py-1 overflow-hidden"
>
<button
v-for="opt in FILTER_OPTIONS"
:key="opt.value"
type="button"
class="w-full text-left px-3.5 py-2 text-sm hover:bg-gold/8 transition-colors"
:class="wishStore.treeFilter === opt.value ? 'text-gold font-semibold bg-gold/6' : 'text-text-main'"
@click="setFilter(opt.value)"
>
{{ opt.label }}
</button>
</div>
</Transition>
</div>
<!-- Tree Wallpaper Background -->
<div class="tree-wallpaper">
<svg class="tree-wallpaper-svg" viewBox="0 0 1200 800" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg">
<!-- Tree 1 - far left -->
<g class="mini-tree" transform="translate(80, 520) scale(0.6)">
<polygon points="50,0 100,70 0,70" fill="currentColor" opacity="0.5" />
<polygon points="50,30 110,110 -10,110" fill="currentColor" opacity="0.45" />
<polygon points="50,70 120,150 -20,150" fill="currentColor" opacity="0.4" />
<rect x="43" y="150" width="14" height="40" rx="3" fill="currentColor" opacity="0.3" />
</g>
<!-- Tree 2 - left-center -->
<g class="mini-tree" transform="translate(260, 440) scale(0.8)">
<polygon points="50,0 95,65 5,65" fill="currentColor" opacity="0.4" />
<polygon points="50,25 105,100 -5,100" fill="currentColor" opacity="0.35" />
<polygon points="50,60 115,140 -15,140" fill="currentColor" opacity="0.3" />
<rect x="44" y="140" width="12" height="35" rx="3" fill="currentColor" opacity="0.25" />
</g>
<!-- Tree 3 - center-left -->
<g class="mini-tree" transform="translate(480, 380) scale(1.0)">
<polygon points="50,0 90,60 10,60" fill="currentColor" opacity="0.35" />
<polygon points="50,20 100,90 0,90" fill="currentColor" opacity="0.3" />
<polygon points="50,50 110,130 -10,130" fill="currentColor" opacity="0.25" />
<rect x="44" y="130" width="12" height="35" rx="3" fill="currentColor" opacity="0.2" />
</g>
<!-- Tree 4 - center (tallest) -->
<g class="mini-tree" transform="translate(680, 300) scale(1.1)">
<polygon points="50,0 85,55 15,55" fill="currentColor" opacity="0.3" />
<polygon points="50,18 95,82 5,82" fill="currentColor" opacity="0.25" />
<polygon points="50,45 105,120 -5,120" fill="currentColor" opacity="0.2" />
<rect x="44" y="120" width="12" height="32" rx="3" fill="currentColor" opacity="0.18" />
</g>
<!-- Tree 5 - right-center -->
<g class="mini-tree" transform="translate(900, 420) scale(0.75)">
<polygon points="50,0 92,62 8,62" fill="currentColor" opacity="0.38" />
<polygon points="50,22 102,95 -2,95" fill="currentColor" opacity="0.32" />
<polygon points="50,55 112,135 -12,135" fill="currentColor" opacity="0.27" />
<rect x="43" y="135" width="14" height="36" rx="3" fill="currentColor" opacity="0.22" />
</g>
<!-- Tree 6 - far right -->
<g class="mini-tree" transform="translate(1080, 500) scale(0.55)">
<polygon points="50,0 96,68 4,68" fill="currentColor" opacity="0.45" />
<polygon points="50,28 108,108 -8,108" fill="currentColor" opacity="0.4" />
<polygon points="50,65 118,148 -18,148" fill="currentColor" opacity="0.35" />
<rect x="43" y="148" width="14" height="38" rx="3" fill="currentColor" opacity="0.28" />
</g>
<!-- Small accent trees -->
<g class="mini-tree" transform="translate(170, 600) scale(0.4)">
<polygon points="50,0 90,60 10,60" fill="currentColor" opacity="0.35" />
<polygon points="50,25 100,90 0,90" fill="currentColor" opacity="0.3" />
<rect x="44" y="90" width="12" height="28" rx="3" fill="currentColor" opacity="0.2" />
</g>
<g class="mini-tree" transform="translate(780, 580) scale(0.35)">
<polygon points="50,0 88,58 12,58" fill="currentColor" opacity="0.32" />
<polygon points="50,22 98,88 2,88" fill="currentColor" opacity="0.27" />
<rect x="44" y="88" width="12" height="25" rx="3" fill="currentColor" opacity="0.18" />
</g>
</svg>
</div>
<!-- Wish Cards Grid -->
<TransitionGroup :name="isMobileViewport ? '' : 'card-enter'" tag="div" class="wish-grid">
<div
v-for="(wish, i) in wishStore.treeWishes"
:key="wish.id"
class="wish-card-slot"
:class="[
CARD_COLORS[i % CARD_COLORS.length],
{ 'is-expired': wish.status === 'expired', 'is-fulfilled': wish.status === 'fulfilled' },
]"
@mouseenter="onCardEnter(wish, $event)"
@mouseleave="onCardLeave"
>
<button type="button" class="cursor-pointer w-full" @click="openWishDetail(wish, $event)">
<span class="text-base">{{ STATUS_ICONS[wish.status] || '\u{1F331}' }}</span>
<div class="mt-1 text-sm font-extrabold leading-tight truncate wish-card-title">{{ wish.animal_name }}</div>
<div class="text-[11px] font-semibold opacity-80 wish-card-meta">W{{ wish.id }}</div>
</button>
</div>
</TransitionGroup>
<!-- Card Detail Popover -->
<Transition name="popover">
<div
v-if="popoverWish"
class="card-popover"
:style="{ left: popoverPos.x + 'px', top: popoverPos.y + 'px' }"
@mouseenter="onPopoverEnter"
@mouseleave="onPopoverLeave"
>
<div class="card-popover-arrow" />
<div class="p-4 text-sm space-y-1.5">
<div class="flex items-center gap-2 mb-2">
<span class="text-base">{{ STATUS_LABELS[popoverWish.status] || STATUS_LABELS.active }}</span>
</div>
<div><span class="text-text-muted">许愿的人:</span> <span class="font-semibold text-text-main">{{ popoverWish.animal_name }}</span></div>
<div><span class="text-text-muted">TA的愿望:</span> {{ popoverWish.content }}</div>
<div><span class="text-text-muted">创建时间:</span> {{ formatDateTime(popoverWish.created_at) }}</div>
<div><span class="text-text-muted">过期时间:</span> {{ formatDateTime(popoverWish.expires_at) }}</div>
<div v-if="popoverWish.ai_response" class="pt-2 mt-2 border-t border-gold/10 text-text-soft leading-relaxed italic">
"{{ popoverWish.ai_response }}"
</div>
<div v-if="isOwner" class="flex items-center gap-2 pt-2 mt-2 border-t border-gold/10">
<button
v-if="canFulfill"
type="button"
class="px-3 h-7 rounded-md bg-amber-500 text-white text-xs font-medium hover:bg-amber-600 transition-colors"
@click.stop="fulfillWish"
>
还愿
</button>
<button
type="button"
class="px-3 h-7 rounded-md bg-red-500 text-white text-xs font-medium hover:bg-red-600 transition-colors"
@click.stop="deleteWish"
>
删除
</button>
</div>
</div>
</div>
</Transition>
<Transition name="sheet">
<div v-if="popoverWish && isMobileViewport" class="mobile-detail-overlay" @click.self="closeWishDetail">
<div class="mobile-detail-sheet">
<div class="mobile-detail-handle" />
<div class="p-4 text-sm space-y-1.5">
<div class="flex items-center justify-between gap-3 mb-2">
<span class="text-base">{{ STATUS_LABELS[popoverWish.status] || STATUS_LABELS.active }}</span>
<button type="button" class="text-text-muted text-lg leading-none" @click="closeWishDetail">&times;</button>
</div>
<div><span class="text-text-muted">许愿的人:</span> <span class="font-semibold text-text-main">{{ popoverWish.animal_name }}</span></div>
<div><span class="text-text-muted">TA的愿望:</span> {{ popoverWish.content }}</div>
<div><span class="text-text-muted">创建时间:</span> {{ formatDateTime(popoverWish.created_at) }}</div>
<div><span class="text-text-muted">过期时间:</span> {{ formatDateTime(popoverWish.expires_at) }}</div>
<div v-if="popoverWish.ai_response" class="pt-2 mt-2 border-t border-gold/10 text-text-soft leading-relaxed italic">
"{{ popoverWish.ai_response }}"
</div>
<div v-if="isOwner" class="flex items-center gap-2 pt-2 mt-2 border-t border-gold/10">
<button
v-if="canFulfill"
type="button"
class="px-3 h-9 rounded-md bg-amber-500 text-white text-sm font-medium"
@click.stop="fulfillWish"
>
还愿
</button>
<button
type="button"
class="px-3 h-9 rounded-md bg-red-500 text-white text-sm font-medium"
@click.stop="deleteWish"
>
删除
</button>
</div>
</div>
</div>
</div>
</Transition>
<!-- Pagination -->
<div class="flex items-center justify-between gap-4 mt-6 px-1 pb-6">
<button
type="button"
class="px-3.5 h-10 rounded-lg bg-white/76 border border-gold/10 text-sm font-medium text-text-main hover:bg-white transition-colors disabled:opacity-40"
:disabled="wishStore.treePage <= 1"
@click="prevPage"
>
← 上一页
</button>
<span class="text-center text-text-soft text-sm font-bold flex-1">
{{ wishStore.treePage }}/{{ wishStore.treeTotalPages }}
<span class="text-text-muted ml-2">共 {{ wishStore.treeTotal }} 个愿望</span>
</span>
<button
type="button"
class="px-3.5 h-10 rounded-lg bg-white/76 border border-gold/10 text-sm font-medium text-text-main hover:bg-white transition-colors disabled:opacity-40"
:disabled="wishStore.treePage >= wishStore.treeTotalPages"
@click="nextPage"
>
下一页 →
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Tree wallpaper background */
.tree-wallpaper {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 0;
}
.tree-wallpaper-svg {
width: 100%;
height: 100%;
color: #8bba7a;
opacity: 0.6;
filter: saturate(0.82) blur(0.2px);
}
.tree-wallpaper::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(255, 250, 241, 0.12) 0%, rgba(255, 250, 241, 0.36) 100%),
linear-gradient(90deg, rgba(255, 250, 241, 0.32) 0%, rgba(255, 250, 241, 0.08) 42%, rgba(255, 250, 241, 0.18) 100%);
}
/* Wish cards grid */
.wish-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
gap: 16px;
padding: 16px 0;
}
.wish-card-slot {
position: relative;
}
.wish-card-slot::before {
content: "";
position: absolute;
left: 50%;
top: -34px;
width: 2px;
height: 36px;
margin-left: -1px;
background: linear-gradient(180deg, rgba(248, 229, 182, 0.96) 0%, rgba(151, 118, 52, 0.38) 100%);
z-index: 0;
}
.wish-card-slot::after {
content: "";
position: absolute;
left: 50%;
top: -6px;
width: 12px;
height: 12px;
margin-left: -6px;
border-radius: 999px;
background: #f4d061;
box-shadow: 0 1px 4px rgba(73, 52, 28, 0.18);
z-index: 1;
}
.wish-card-slot button {
min-height: 74px;
padding: 10px 8px;
border-radius: 12px;
border: 1px solid rgba(67, 48, 31, 0.14);
box-shadow: 0 12px 24px rgba(70, 50, 30, 0.14);
font-weight: 700;
white-space: normal;
line-height: 1.2;
width: 100%;
text-align: left;
background: none;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.2);
}
/* Card colors */
.card-rose button { background: linear-gradient(135deg, rgba(255, 213, 222, 0.96) 0%, rgba(255, 235, 239, 0.98) 100%); color: #6d2436; }
.card-amber button { background: linear-gradient(135deg, rgba(255, 228, 181, 0.96) 0%, rgba(255, 242, 215, 0.98) 100%); color: #71440a; }
.card-mint button { background: linear-gradient(135deg, rgba(217, 245, 234, 0.96) 0%, rgba(239, 251, 246, 0.98) 100%); color: #1e5743; }
.card-sky button { background: linear-gradient(135deg, rgba(216, 235, 255, 0.96) 0%, rgba(238, 246, 255, 0.98) 100%); color: #1a467d; }
.card-violet button { background: linear-gradient(135deg, rgba(234, 220, 255, 0.96) 0%, rgba(245, 239, 255, 0.98) 100%); color: #51317e; }
.card-peach button { background: linear-gradient(135deg, rgba(255, 220, 202, 0.96) 0%, rgba(255, 241, 232, 0.98) 100%); color: #7a3d21; }
.wish-card-title {
letter-spacing: 0.01em;
}
.wish-card-meta {
color: rgba(47, 38, 29, 0.74);
}
/* Card hover popover */
.card-popover {
position: fixed;
z-index: 100;
width: min(320px, calc(100vw - 32px));
transform: translateX(-50%);
background: rgba(255, 253, 247, 0.98);
border: 1px solid rgba(98, 73, 43, 0.12);
border-radius: 16px;
box-shadow: 0 20px 60px rgba(47, 38, 29, 0.18);
backdrop-filter: blur(12px);
}
.card-popover-arrow {
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
width: 12px;
height: 12px;
background: rgba(255, 253, 247, 0.98);
border-left: 1px solid rgba(98, 73, 43, 0.12);
border-top: 1px solid rgba(98, 73, 43, 0.12);
}
/* Popover transition */
.popover-enter-active,
.popover-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.popover-enter-from,
.popover-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(4px);
}
.mobile-detail-overlay {
position: fixed;
inset: 0;
z-index: 110;
background: rgba(26, 20, 12, 0.32);
display: flex;
align-items: flex-end;
}
.mobile-detail-sheet {
width: 100%;
background: rgba(255, 253, 247, 0.98);
border-radius: 20px 20px 0 0;
box-shadow: 0 -12px 36px rgba(47, 38, 29, 0.18);
backdrop-filter: blur(12px);
padding-bottom: max(16px, env(safe-area-inset-bottom));
}
.mobile-detail-handle {
width: 44px;
height: 5px;
border-radius: 999px;
background: rgba(120, 96, 61, 0.24);
margin: 10px auto 2px;
}
/* Card states */
.wish-card-slot.is-expired button {
background: linear-gradient(135deg, #e6e1da 0%, #f4f1ed 100%) !important;
color: #7a7064 !important;
box-shadow: 0 8px 18px rgba(90, 80, 68, 0.1) !important;
filter: saturate(0.4);
}
.wish-card-slot.is-fulfilled button {
background: linear-gradient(135deg, #dbead2 0%, #edf6e8 100%) !important;
color: #47613d !important;
border-color: rgba(71, 97, 61, 0.18) !important;
box-shadow: 0 12px 24px rgba(71, 97, 61, 0.14) !important;
}
/* Mobile responsive */
@media (max-width: 768px) {
.card-popover {
display: none;
}
.wish-grid {
grid-template-columns: repeat(auto-fill, minmax(98px, 1fr));
gap: 12px;
}
.wish-card-slot button {
min-height: 64px;
padding: 8px 6px;
font-size: 0.78rem;
}
.wish-card-slot::before {
height: 28px;
top: -28px;
}
.wish-card-slot::after {
width: 10px;
height: 10px;
top: -5px;
margin-left: -5px;
}
.card-enter-enter-active,
.card-enter-leave-active {
position: relative;
transition: none !important;
}
.card-enter-enter-from,
.card-enter-leave-to {
opacity: 1;
transform: none;
}
}
.sheet-enter-active,
.sheet-leave-active {
transition: opacity 0.2s ease;
}
.sheet-enter-active .mobile-detail-sheet,
.sheet-leave-active .mobile-detail-sheet {
transition: transform 0.2s ease;
}
.sheet-enter-from,
.sheet-leave-to {
opacity: 0;
}
.sheet-enter-from .mobile-detail-sheet,
.sheet-leave-to .mobile-detail-sheet {
transform: translateY(100%);
}
/* Card enter animation */
.card-enter-enter-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.card-enter-enter-from {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
.card-enter-leave-active {
position: absolute;
transition: opacity 0.14s ease;
}
.card-enter-leave-to {
opacity: 0;
}
/* Filter dropdown transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Mobile panel transition */
.slide-enter-active,
.slide-leave-active {
transition: opacity 0.2s ease;
}
.slide-enter-active > div,
.slide-leave-active > div {
transition: transform 0.2s ease;
}
.slide-enter-from,
.slide-leave-to {
opacity: 0;
}
.slide-enter-from > div,
.slide-leave-to > div {
transform: translateX(100%);
}
</style>