<template>
|
<div class="carousel-cards">
|
<button
|
v-if="canScrollLeft"
|
class="nav-button nav-button-left"
|
aria-label="向左滚动"
|
@click="scrollLeftFn"
|
>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<path d="M15 18l-6-6 6-6"/>
|
</svg>
|
</button>
|
<div
|
class="cards-container"
|
:style="{ '--visible-count': visibleCount }"
|
ref="cardsContainerRef"
|
>
|
<div
|
v-for="(item, index) in items"
|
:key="index"
|
class="card-item"
|
>
|
<div v-if="item.icon" class="card-icon" :style="{ backgroundImage: `url(${item.icon})` }"></div>
|
<div class="card-title">
|
<div class="card-label">{{ item.label }}</div>
|
<div class="card-value">
|
<span class="value-number">{{ item.value }}</span>
|
<span class="value-unit">{{ item.unit }}</span>
|
</div>
|
<div v-if="item.rate ?? item.ratio ?? item.percent" class="card-rate">
|
<span class="rate-value">{{ item.rate ?? item.ratio ?? item.percent }}%</span>
|
</div>
|
</div>
|
</div>
|
</div>
|
<button
|
v-if="canScrollRight"
|
class="nav-button nav-button-right"
|
aria-label="向右滚动"
|
@click="scrollRightFn"
|
>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<path d="M9 6l6 6-6 6"/>
|
</svg>
|
</button>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, onMounted, onBeforeUnmount, nextTick, watch, computed } from 'vue'
|
|
const props = defineProps({
|
items: {
|
type: Array,
|
default: () => [],
|
validator: (value) => {
|
return value.every(item =>
|
item && typeof item.label !== 'undefined' &&
|
typeof item.value !== 'undefined' &&
|
typeof item.unit !== 'undefined'
|
)
|
}
|
},
|
visibleCount: {
|
type: Number,
|
default: 3
|
}
|
})
|
|
const cardsContainerRef = ref(null)
|
const currentScrollLeft = ref(0)
|
const maxScrollLeft = ref(0)
|
|
// 检查是否可以向左滚动
|
const canScrollLeft = computed(() => {
|
return currentScrollLeft.value > 0
|
})
|
|
// 检查是否可以向右滚动
|
const canScrollRight = computed(() => {
|
return currentScrollLeft.value < maxScrollLeft.value
|
})
|
|
// 更新滚动状态
|
const updateScrollState = () => {
|
const container = cardsContainerRef.value
|
if (!container) return
|
|
currentScrollLeft.value = container.scrollLeft
|
maxScrollLeft.value = container.scrollWidth - container.clientWidth
|
}
|
|
// 向左滚动
|
const scrollLeftFn = () => {
|
const container = cardsContainerRef.value
|
if (!container) return
|
|
const scrollItems = Array.from(container.querySelectorAll('.card-item'))
|
if (scrollItems.length === 0) return
|
|
const itemWidth = scrollItems[0]?.offsetWidth || 0
|
const gap = 12
|
const scrollDistance = itemWidth + gap
|
|
container.scrollBy({
|
left: -scrollDistance,
|
behavior: 'smooth'
|
})
|
|
// 延迟更新状态,等待滚动动画完成
|
setTimeout(() => {
|
updateScrollState()
|
}, 300)
|
}
|
|
// 向右滚动
|
const scrollRightFn = () => {
|
const container = cardsContainerRef.value
|
if (!container) return
|
|
const scrollItems = Array.from(container.querySelectorAll('.card-item'))
|
if (scrollItems.length === 0) return
|
|
const itemWidth = scrollItems[0]?.offsetWidth || 0
|
const gap = 12
|
const scrollDistance = itemWidth + gap
|
|
container.scrollBy({
|
left: scrollDistance,
|
behavior: 'smooth'
|
})
|
|
// 延迟更新状态,等待滚动动画完成
|
setTimeout(() => {
|
updateScrollState()
|
}, 300)
|
}
|
|
// 监听 items 变化,更新滚动状态
|
watch(() => props.items, () => {
|
nextTick(() => {
|
updateScrollState()
|
})
|
}, { deep: true })
|
|
onMounted(() => {
|
nextTick(() => {
|
updateScrollState()
|
// 监听滚动事件
|
const container = cardsContainerRef.value
|
if (container) {
|
container.addEventListener('scroll', updateScrollState)
|
}
|
})
|
})
|
|
onBeforeUnmount(() => {
|
// 清理滚动事件监听器
|
const container = cardsContainerRef.value
|
if (container) {
|
container.removeEventListener('scroll', updateScrollState)
|
}
|
})
|
</script>
|
|
<style scoped>
|
.carousel-cards {
|
width: 100%;
|
overflow: hidden;
|
position: relative;
|
display: flex;
|
align-items: center;
|
}
|
|
.cards-container {
|
display: flex;
|
gap: 12px;
|
width: 100%;
|
overflow-x: auto;
|
overflow-y: hidden;
|
scrollbar-width: none; /* Firefox */
|
-ms-overflow-style: none; /* IE and Edge */
|
padding-bottom: 4px;
|
scroll-behavior: smooth;
|
}
|
|
.cards-container::-webkit-scrollbar {
|
display: none; /* Chrome, Safari, Opera */
|
}
|
|
.nav-button {
|
position: absolute;
|
top: 50%;
|
transform: translateY(-50%);
|
width: 28px;
|
height: 28px;
|
background: var(--bi-panel-bg, rgba(20, 32, 66, 0.55));
|
border: 1px solid var(--bi-panel-border, #1e3160);
|
border-radius: 50%;
|
color: var(--bi-text, #8fa3c8);
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
cursor: pointer;
|
z-index: 10;
|
transition: color 0.2s ease, border-color 0.2s ease;
|
padding: 0;
|
}
|
|
.nav-button:hover {
|
color: var(--bi-text-strong, #eaf1ff);
|
border-color: var(--bi-accent-bright, #4a6cf0);
|
}
|
|
.nav-button-left {
|
left: -14px;
|
}
|
|
.nav-button-right {
|
right: -14px;
|
}
|
|
.card-item {
|
flex: 0 0 calc((100% - (var(--visible-count) - 1) * 12px) / var(--visible-count));
|
min-width: calc((100% - (var(--visible-count) - 1) * 12px) / var(--visible-count));
|
display: flex;
|
align-items: center;
|
background: var(--bi-panel-bg, rgba(20, 32, 66, 0.55));
|
border: 1px solid var(--bi-panel-border, #1e3160);
|
border-left: 2px solid var(--bi-accent-bright, #4a6cf0);
|
border-radius: 6px;
|
padding: 12px 16px;
|
transition: border-color 0.2s ease, background-color 0.2s ease;
|
}
|
|
.card-item:hover {
|
background: rgba(33, 49, 96, 0.65);
|
border-color: var(--bi-accent-bright, #4a6cf0);
|
}
|
|
.card-icon {
|
width: 80px;
|
height: 60px;
|
background-size: cover;
|
background-position: center;
|
background-repeat: no-repeat;
|
flex-shrink: 0;
|
margin-right: 12px;
|
}
|
|
.card-title {
|
display: flex;
|
align-items: flex-start;
|
flex-direction: column;
|
flex: 1;
|
}
|
|
.card-label {
|
font-weight: 400;
|
font-size: 14px;
|
color: var(--bi-text, #8fa3c8);
|
margin-bottom: 4px;
|
white-space: nowrap;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
width: 100%;
|
}
|
|
.card-value {
|
display: flex;
|
align-items: baseline;
|
gap: 4px;
|
}
|
|
.card-rate {
|
margin-top: 4px;
|
display: flex;
|
align-items: center;
|
gap: 6px;
|
font-weight: 400;
|
font-size: 12px;
|
color: var(--bi-text, #8fa3c8);
|
}
|
|
.rate-label {
|
opacity: 0.85;
|
}
|
|
.rate-value {
|
font-weight: 500;
|
}
|
|
.value-number {
|
font-weight: 500;
|
font-size: 16px;
|
color: var(--bi-text-strong, #eaf1ff);
|
line-height: 1;
|
font-variant-numeric: tabular-nums;
|
}
|
|
.value-unit {
|
font-size: 12px;
|
color: var(--bi-text, #8fa3c8);
|
font-weight: 400;
|
}
|
</style>
|