| | |
| | | <template> |
| | | <div class="carousel-cards"> |
| | | <button |
| | | v-if="canScrollLeft" |
| | | class="nav-button nav-button-left" |
| | | @click="scrollLeftFn" |
| | | > |
| | | <img src="@/assets/BI/jiantou.png" alt="左箭头" /> |
| | | <button v-if="canScrollLeft" |
| | | class="nav-button nav-button-left" |
| | | @click="scrollLeftFn"> |
| | | <img src="@/assets/BI/jiantou.png" |
| | | alt="左箭头" /> |
| | | </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="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"> |
| | | <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" |
| | | @click="scrollRightFn" |
| | | > |
| | | <img src="@/assets/BI/jiantou.png" alt="右箭头" /> |
| | | <button v-if="canScrollRight" |
| | | class="nav-button nav-button-right" |
| | | @click="scrollRightFn"> |
| | | <img src="@/assets/BI/jiantou.png" |
| | | alt="右箭头" /> |
| | | </button> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, onMounted, onBeforeUnmount, nextTick, watch, computed } from 'vue' |
| | | 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 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 cardsContainerRef = ref(null); |
| | | const currentScrollLeft = ref(0); |
| | | const maxScrollLeft = ref(0); |
| | | |
| | | // 检查是否可以向左滚动 |
| | | const canScrollLeft = computed(() => { |
| | | return currentScrollLeft.value > 0 |
| | | }) |
| | | // 检查是否可以向左滚动 |
| | | const canScrollLeft = computed(() => { |
| | | return currentScrollLeft.value > 0; |
| | | }); |
| | | |
| | | // 检查是否可以向右滚动 |
| | | const canScrollRight = computed(() => { |
| | | return currentScrollLeft.value < maxScrollLeft.value |
| | | }) |
| | | // 检查是否可以向右滚动 |
| | | 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 updateScrollState = () => { |
| | | const container = cardsContainerRef.value; |
| | | if (!container) return; |
| | | |
| | | // 向左滚动 |
| | | 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) |
| | | } |
| | | currentScrollLeft.value = container.scrollLeft; |
| | | maxScrollLeft.value = container.scrollWidth - container.clientWidth; |
| | | }; |
| | | |
| | | // 向右滚动 |
| | | 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) |
| | | } |
| | | // 向左滚动 |
| | | const scrollLeftFn = () => { |
| | | const container = cardsContainerRef.value; |
| | | if (!container) return; |
| | | |
| | | // 监听 items 变化,更新滚动状态 |
| | | watch(() => props.items, () => { |
| | | nextTick(() => { |
| | | updateScrollState() |
| | | }) |
| | | }, { deep: true }) |
| | | const scrollItems = Array.from(container.querySelectorAll(".card-item")); |
| | | if (scrollItems.length === 0) return; |
| | | |
| | | onMounted(() => { |
| | | nextTick(() => { |
| | | updateScrollState() |
| | | // 监听滚动事件 |
| | | const container = cardsContainerRef.value |
| | | 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.addEventListener('scroll', updateScrollState) |
| | | container.removeEventListener("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; |
| | | } |
| | | .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 { |
| | | 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 */ |
| | | } |
| | | .cards-container::-webkit-scrollbar { |
| | | display: none; /* Chrome, Safari, Opera */ |
| | | } |
| | | |
| | | .nav-button { |
| | | position: absolute; |
| | | top: 50%; |
| | | transform: translateY(-50%); |
| | | width: 32px; |
| | | height: 32px; |
| | | background: rgba(26, 88, 176, 0.6); |
| | | border: 1px solid rgba(26, 88, 176, 0.8); |
| | | border-radius: 50%; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | cursor: pointer; |
| | | z-index: 10; |
| | | transition: all 0.3s ease; |
| | | padding: 0; |
| | | } |
| | | .nav-button { |
| | | position: absolute; |
| | | top: 50%; |
| | | transform: translateY(-50%); |
| | | width: 32px; |
| | | height: 32px; |
| | | background: rgba(26, 88, 176, 0.6); |
| | | border: 1px solid rgba(26, 88, 176, 0.8); |
| | | border-radius: 50%; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | cursor: pointer; |
| | | z-index: 10; |
| | | transition: all 0.3s ease; |
| | | padding: 0; |
| | | } |
| | | |
| | | .nav-button:hover { |
| | | background: rgba(26, 88, 176, 0.8); |
| | | transform: translateY(-50%) scale(1.1); |
| | | } |
| | | .nav-button:hover { |
| | | background: rgba(26, 88, 176, 0.8); |
| | | transform: translateY(-50%) scale(1.1); |
| | | } |
| | | |
| | | .nav-button-left { |
| | | left: -16px; |
| | | } |
| | | .nav-button-left { |
| | | left: -16px; |
| | | } |
| | | |
| | | .nav-button-left img { |
| | | width: 16px; |
| | | height: 16px; |
| | | transform: rotate(180deg); |
| | | } |
| | | .nav-button-left img { |
| | | width: 16px; |
| | | height: 16px; |
| | | transform: rotate(180deg); |
| | | } |
| | | |
| | | .nav-button-right { |
| | | right: -16px; |
| | | } |
| | | .nav-button-right { |
| | | right: -16px; |
| | | } |
| | | |
| | | .nav-button-right img { |
| | | width: 16px; |
| | | height: 16px; |
| | | } |
| | | .nav-button-right img { |
| | | width: 16px; |
| | | height: 16px; |
| | | } |
| | | |
| | | .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: linear-gradient(269deg, rgba(27,57,126,0.13) 0%, rgba(33,137,206,0.33) 98.13%, #24AFF4 100%); |
| | | border-radius: 8px 8px 8px 8px; |
| | | padding: 12px 16px; |
| | | transition: all 0.3s ease; |
| | | } |
| | | .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: linear-gradient( |
| | | 269deg, |
| | | rgba(27, 57, 126, 0.13) 0%, |
| | | rgba(33, 137, 206, 0.33) 98.13%, |
| | | #24aff4 100% |
| | | ); |
| | | border-radius: 8px 8px 8px 8px; |
| | | padding: 12px 16px; |
| | | transition: all 0.3s ease; |
| | | } |
| | | |
| | | .card-item:hover { |
| | | transform: translateY(-2px); |
| | | } |
| | | .card-item:hover { |
| | | transform: translateY(-2px); |
| | | } |
| | | |
| | | .card-icon { |
| | | width: 80px; |
| | | height: 60px; |
| | | background-size: cover; |
| | | background-position: center; |
| | | background-repeat: no-repeat; |
| | | flex-shrink: 0; |
| | | margin-right: 12px; |
| | | } |
| | | .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-title { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | flex-direction: column; |
| | | flex: 1; |
| | | } |
| | | |
| | | .card-label { |
| | | font-weight: 400; |
| | | font-size: 14px; |
| | | color: #FFFFFF; |
| | | margin-bottom: 4px; |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | width: 100%; |
| | | } |
| | | .card-label { |
| | | font-weight: 400; |
| | | font-size: 14px; |
| | | color: #ffffff; |
| | | margin-bottom: 4px; |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | width: 100%; |
| | | } |
| | | |
| | | .card-value { |
| | | display: flex; |
| | | align-items: baseline; |
| | | gap: 4px; |
| | | } |
| | | .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: rgba(255, 255, 255, 0.85); |
| | | } |
| | | .card-rate { |
| | | margin-top: 4px; |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 6px; |
| | | font-weight: 400; |
| | | font-size: 12px; |
| | | color: rgba(255, 255, 255, 0.85); |
| | | } |
| | | |
| | | .rate-label { |
| | | opacity: 0.85; |
| | | } |
| | | .rate-label { |
| | | opacity: 0.85; |
| | | } |
| | | |
| | | .rate-value { |
| | | font-weight: 500; |
| | | } |
| | | .rate-value { |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .value-number { |
| | | font-weight: 400; |
| | | font-size: 14px; |
| | | color: #FFFFFF; |
| | | line-height: 1; |
| | | } |
| | | .value-number { |
| | | font-weight: 400; |
| | | font-size: 14px; |
| | | color: #ffffff; |
| | | line-height: 1; |
| | | } |
| | | |
| | | .value-unit { |
| | | font-size: 14px; |
| | | color: #FFFFFF; |
| | | font-weight: 400; |
| | | } |
| | | .value-unit { |
| | | font-size: 14px; |
| | | color: #ffffff; |
| | | font-weight: 400; |
| | | } |
| | | </style> |