Commit e159daa8 authored by 罗超's avatar 罗超

新增手风琴卡片

parent af3e1ed6
<template>
<div
class="accordion-gallery"
:style="{
gap: `${gap}px`,
height: `${height}px`,
}"
>
<div
v-for="(item, index) in items"
:key="item.id || index"
class="accordion-item"
:class="{ 'is-active': index === activeIndex }"
:style="getItemStyle(index)"
@mouseenter="handleMouseEnter(index)"
@focus="handleMouseEnter(index)"
@click="handleClick(item)"
tabindex="0"
>
<!-- 背景图 -->
<div
class="accordion-item-bg"
:style="{
backgroundImage: `url(${item.image})`,
borderRadius: `${borderRadius}px`,
}"
/>
<!-- 遮罩层 -->
<div
class="accordion-item-overlay"
:style="{
borderRadius: `${borderRadius}px`,
}"
/>
<!-- 内容层 -->
<div
class="accordion-item-content"
:class="{ 'content-expanded': index === activeIndex }"
>
<div class="title-row">
<h3 class="title">
{{ item.title }}
</h3>
</div>
<!-- 仅在激活状态下展示标签和按钮 -->
<transition name="fade">
<div
v-if="index === activeIndex"
class="extra-content flex items-center gap-2"
>
<!-- 标签(最多展示 2 个,其余隐藏) -->
<div v-if="item.tags && item.tags.length" class="tags gap-2">
<a-tag
v-for="(tag, tagIndex) in getVisibleTags(item.tags)"
:key="tag.id || `${index}_${tagIndex}`"
class="!border-gray-100/30 !bg-gray-50/10 !text-gray-50 cursor-pointer !rounded-full"
bordered
@click.stop="handleTagClick(tag)"
>
{{ tag.label }}
</a-tag>
</div>
<!-- 查看更多按钮:有链接时显示 -->
<a-button
v-if="item.link"
class="!py-1 !px-2 !h-auto !text-xs !rounded-full !bg-slate-50/30"
size="small"
shape="round"
type="primary"
@click.stop="handleClick(item)"
>
查看更多
</a-button>
</div>
</transition>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import { computed, ref } from 'vue'
import type { AccordionGalleryItem, AccordionTag } from '@/types/page-builder/accordionGallery'
const props = defineProps({
items: {
type: Array as PropType<AccordionGalleryItem[]>,
default: () => [],
},
/** 组件整体高度(px) */
height: {
type: Number,
default: 260,
},
/** 普通项权重(flex 值) */
collapsedFlex: {
type: Number,
default: 1,
},
/** 激活项权重(flex 值) */
expandedFlex: {
type: Number,
default: 2.4,
},
/** 卡片之间的间距(px) */
gap: {
type: Number,
default: 12,
},
/** 卡片圆角(px) */
borderRadius: {
type: Number,
default: 16,
},
/** 动画时长(ms) */
duration: {
type: Number,
default: 260,
},
})
const activeIndex = ref(0)
const getItemStyle = (index: number) => {
const isActive = index === activeIndex.value
const flex = isActive ? props.expandedFlex : props.collapsedFlex
return {
flex,
transition: `flex ${props.duration}ms ease`,
}
}
const handleMouseEnter = (index: number) => {
if (index === activeIndex.value) return
activeIndex.value = index
}
const handleClick = (item: AccordionGalleryItem) => {
if (!item.link) return
if (item.openInNewTab) {
window.open(item.link, '_blank')
} else {
window.location.href = item.link
}
}
const handleTagClick = (tag: AccordionTag) => {
if (!tag.link) return
window.open(tag.link, '_blank')
}
const getVisibleTags = (tags: AccordionTag[]) => tags.slice(0, 2)
</script>
<style scoped>
.accordion-gallery {
display: flex;
align-items: stretch;
width: 100%;
}
.accordion-item {
position: relative;
min-width: 0;
outline: none;
}
.accordion-item-bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
transform-origin: center;
transition: transform 0.26s ease;
}
.accordion-item-overlay {
position: absolute;
inset: 0;
background: linear-gradient(transparent 60%, rgba(0, 0, 0, .85));
pointer-events: none;
}
.accordion-item-content {
position: relative;
z-index: 1;
height: 100%;
padding: 5px 18px;
display: flex;
flex-direction: column;
justify-content: flex-end;
color: #ffffff;
}
.title-row {
margin-bottom: 8px;
}
.title {
margin: 0;
font-size: 20px;
font-weight: 700;
letter-spacing: 0.04em;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}
.subtitle {
display: none;
}
.tags {
display: flex;
flex-wrap: wrap;
}
.tag-pill:hover {
border-color: rgba(29, 33, 41, 0.3);
}
.accordion-item.is-active .accordion-item-content {
padding-bottom: 20px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media (max-width: 768px) {
.accordion-gallery {
flex-direction: column;
height: auto !important;
}
.accordion-item {
height: 220px;
}
}
</style>
...@@ -5,10 +5,11 @@ ...@@ -5,10 +5,11 @@
'outside-nav': isOutsideNav && !isAbsolutePosition, 'outside-nav': isOutsideNav && !isAbsolutePosition,
'outside-nav-absolute': isOutsideNav && isAbsolutePosition 'outside-nav-absolute': isOutsideNav && isAbsolutePosition
}" }"
ref="wrapperRef"
:style="wrapperStyle" :style="wrapperStyle"
> >
<!-- 外部左箭头 --> <!-- 外部左箭头 -->
<template v-if="isOutsideNav && props.navigation.enabled"> <template v-if="isOutsideNav && props.navigation.enabled && showPrevArrow">
<div <div
class="outside-nav-btn outside-nav-prev" class="outside-nav-btn outside-nav-prev"
:class="{ 'absolute-position': isAbsolutePosition }" :class="{ 'absolute-position': isAbsolutePosition }"
...@@ -41,6 +42,7 @@ ...@@ -41,6 +42,7 @@
:key="swiperKey" :key="swiperKey"
:modules="modules" :modules="modules"
:slides-per-view="actualSlidesPerView" :slides-per-view="actualSlidesPerView"
:slides-per-group="slidesPerGroup"
:space-between="props.spaceBetween" :space-between="props.spaceBetween"
:centered-slides="props.centeredSlides" :centered-slides="props.centeredSlides"
:grab-cursor="props.grabCursor" :grab-cursor="props.grabCursor"
...@@ -192,7 +194,7 @@ ...@@ -192,7 +194,7 @@
<component :is="ComponentRenderer" :node="virtualSearchBoxNode" /> <component :is="ComponentRenderer" :node="virtualSearchBoxNode" />
</div> </div>
<!-- 外部右箭头 --> <!-- 外部右箭头 -->
<template v-if="isOutsideNav && props.navigation.enabled"> <template v-if="isOutsideNav && props.navigation.enabled && showNextArrow">
<div <div
class="outside-nav-btn outside-nav-next" class="outside-nav-btn outside-nav-next"
:class="{ 'absolute-position': isAbsolutePosition }" :class="{ 'absolute-position': isAbsolutePosition }"
...@@ -223,7 +225,7 @@ ...@@ -223,7 +225,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, inject, onUnmounted } from 'vue' import { computed, ref, watch, inject, onMounted, onUnmounted } from 'vue'
import type { CSSProperties } from 'vue' import type { CSSProperties } from 'vue'
import { Swiper, SwiperSlide } from 'swiper/vue' import { Swiper, SwiperSlide } from 'swiper/vue'
import { Navigation, Pagination, Autoplay, EffectFade, EffectCube, EffectCoverflow, EffectFlip, EffectCards, EffectCreative, Keyboard } from 'swiper/modules' import { Navigation, Pagination, Autoplay, EffectFade, EffectCube, EffectCoverflow, EffectFlip, EffectCards, EffectCreative, Keyboard } from 'swiper/modules'
...@@ -382,6 +384,23 @@ const isOutsideNav = computed(() => { ...@@ -382,6 +384,23 @@ const isOutsideNav = computed(() => {
return props.navigation.enabled && props.navigation.position === 'outside' return props.navigation.enabled && props.navigation.position === 'outside'
}) })
// 非循环模式下,控制左右箭头显示
const showPrevArrow = computed(() => {
if (!props.navigation.enabled) return false
// 循环播放下始终显示
if (props.loop) return true
// 非循环:只有在不是第一页时显示
if (!swiperInstance.value) return false
return !swiperInstance.value.isBeginning
})
const showNextArrow = computed(() => {
if (!props.navigation.enabled) return false
if (props.loop) return true
if (!swiperInstance.value) return false
return !swiperInstance.value.isEnd
})
// 判断外部箭头是否使用绝对定位(不占用组件宽度) // 判断外部箭头是否使用绝对定位(不占用组件宽度)
const isAbsolutePosition = computed(() => { const isAbsolutePosition = computed(() => {
return isOutsideNav.value && (props.navigation.absolutePosition === true) return isOutsideNav.value && (props.navigation.absolutePosition === true)
...@@ -522,6 +541,15 @@ const actualSlidesPerView = computed(() => { ...@@ -522,6 +541,15 @@ const actualSlidesPerView = computed(() => {
return props.slidesPerView return props.slidesPerView
}) })
// 每次切换的幻灯片数量:当一页显示多张时,一次切换多张
const slidesPerGroup = computed(() => {
const val = actualSlidesPerView.value
if (typeof val === 'number' && val > 1) {
return val
}
return 1
})
// 计算实际的 effect(多张显示时自动切换到支持的特效) // 计算实际的 effect(多张显示时自动切换到支持的特效)
const actualEffect = computed(() => { const actualEffect = computed(() => {
// 如果要求显示多张,但当前特效不支持,自动切换到 slide // 如果要求显示多张,但当前特效不支持,自动切换到 slide
...@@ -652,14 +680,61 @@ const carouselStyle = computed(() => { ...@@ -652,14 +680,61 @@ const carouselStyle = computed(() => {
return style return style
}) })
// 图片样式(固定 cover) // 图片滚动缩放效果:根据组件在视口中的位置从 1 ~ 1.2 缩放
const imageStyle = computed<CSSProperties>(() => ({ const wrapperRef = ref<HTMLElement | null>(null)
width: '100%', const scrollScale = ref(1)
height: '100%',
objectFit: 'cover', const updateScrollScale = () => {
display: 'block', const el = wrapperRef.value
borderRadius:props.borderRadius + 'px', if (!el) return
}))
const rect = el.getBoundingClientRect()
const vh = window.innerHeight || document.documentElement.clientHeight || 0
if (vh === 0) return
// 元素中心点相对视口中心的位置
const elementCenter = rect.top + rect.height / 2
const viewportCenter = vh / 2
const distance = Math.abs(elementCenter - viewportCenter)
// 距离越近,t 越接近 1;距离超过 1 个视口高度则视为 0
const maxDistance = vh
const t = Math.max(0, 1 - distance / maxDistance)
// 在 1 ~ 1.2 之间插值
const minScale = 1
const maxScale = 1.2
scrollScale.value = minScale + (maxScale - minScale) * t
}
onMounted(() => {
// 仅在浏览器环境下,根据滚动和窗口大小变化更新缩放
if(props.maxWidth>0) return
updateScrollScale()
document.querySelector("#app-document")?.addEventListener('scroll', updateScrollScale, { passive: true })
document.querySelector("#app-document")?.addEventListener('resize', updateScrollScale, { passive: true })
})
onUnmounted(() => {
if(props.maxWidth>0) return
document.querySelector("#app-document")?.removeEventListener('scroll', updateScrollScale)
document.querySelector("#app-document")?.removeEventListener('resize', updateScrollScale)
})
const imageStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
borderRadius: props.borderRadius + 'px',
willChange: 'transform',
transform: `scale(${scrollScale.value})`,
transition: 'transform 0.3s ease-out',
}
return style
})
// 处理图片点击 // 处理图片点击
const handleImageClick = (image: typeof props.images[0]) => { const handleImageClick = (image: typeof props.images[0]) => {
...@@ -1144,6 +1219,12 @@ const handleButtonClick = (buttonLink?: string) => { ...@@ -1144,6 +1219,12 @@ const handleButtonClick = (buttonLink?: string) => {
} }
} }
} }
// 非循环模式下,Swiper 会在边界页给按钮加 disabled 类,这里直接隐藏
:deep(.swiper-button-prev.swiper-button-disabled),
:deep(.swiper-button-next.swiper-button-disabled) {
display: none;
}
:deep(.swiper-button-prev) { :deep(.swiper-button-prev) {
left: v-bind('navigationLeftPosition'); left: v-bind('navigationLeftPosition');
......
...@@ -61,6 +61,9 @@ ...@@ -61,6 +61,9 @@
<template v-else-if="node.type === 'searchBox'"> <template v-else-if="node.type === 'searchBox'">
<SearchBox v-bind="(node.props as any)" /> <SearchBox v-bind="(node.props as any)" />
</template> </template>
<template v-else-if="node.type === 'accordion-gallery'">
<AccordionGallery v-bind="(node.props as any)" />
</template>
<!-- floating-ad 通过左侧全局组件面板管理,不在画布中渲染 --> <!-- floating-ad 通过左侧全局组件面板管理,不在画布中渲染 -->
<template v-else-if="node.type === 'container'"> <template v-else-if="node.type === 'container'">
<ComponentRenderer <ComponentRenderer
...@@ -74,7 +77,6 @@ ...@@ -74,7 +77,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, provide } from 'vue' import { computed, provide } from 'vue'
import { Message } from '@arco-design/web-vue'
import { useRenderContext } from '@/contexts/renderContext' import { useRenderContext } from '@/contexts/renderContext'
import ImageGallery from './ImageGallery.vue' import ImageGallery from './ImageGallery.vue'
import Video from './Video.vue' import Video from './Video.vue'
...@@ -89,6 +91,7 @@ import Card from './Card.vue' ...@@ -89,6 +91,7 @@ import Card from './Card.vue'
import GridContainer from './GridContainer.vue' import GridContainer from './GridContainer.vue'
import Carousel from './Carousel.vue' import Carousel from './Carousel.vue'
import SearchBox from './SearchBox.vue' import SearchBox from './SearchBox.vue'
import AccordionGallery from './AccordionGallery.vue'
import type { ComponentNode } from '@/types/pageBuilder' import type { ComponentNode } from '@/types/pageBuilder'
const props = defineProps<{ const props = defineProps<{
......
<template> <template>
<div class="h-screen overflow-y-auto"> <div class="h-screen overflow-y-auto" id="app-document">
<!-- 顶部工具栏 --> <!-- 顶部工具栏 -->
<Headers /> <Headers />
<!-- 页面内容 --> <!-- 页面内容 -->
......
...@@ -107,6 +107,7 @@ const componentMap: Record<string, Component> = { ...@@ -107,6 +107,7 @@ const componentMap: Record<string, Component> = {
'divider': defineAsyncComponent(() => import('@/components/page-builder/Divider.vue')), 'divider': defineAsyncComponent(() => import('@/components/page-builder/Divider.vue')),
// 业务组件 // 业务组件
'accordion-gallery': defineAsyncComponent(() => import('@/components/page-builder/AccordionGallery.vue')),
'card': defineAsyncComponent(() => import('@/components/page-builder/Card.vue')), 'card': defineAsyncComponent(() => import('@/components/page-builder/Card.vue')),
'banner': defineAsyncComponent(() => import('@/components/page-builder/Banner.vue')), 'banner': defineAsyncComponent(() => import('@/components/page-builder/Banner.vue')),
'parallax': defineAsyncComponent(() => import('@/components/page-builder/Parallax.vue')), 'parallax': defineAsyncComponent(() => import('@/components/page-builder/Parallax.vue')),
......
...@@ -49,6 +49,12 @@ export interface CarouselProps { ...@@ -49,6 +49,12 @@ export interface CarouselProps {
duration: number // 缩放时长(秒)2-10 duration: number // 缩放时长(秒)2-10
} }
// 滚动视差效果(根据页面上下滚动轻微位移图片)
scrollParallax?: {
enabled: boolean // 是否启用滚动视差
intensity: number // 位移强度(建议 0.1 - 0.5)
}
// 内容对齐(支持子元素) // 内容对齐(支持子元素)
contentAlign: { contentAlign: {
horizontal: 'left' | 'center' | 'right' horizontal: 'left' | 'center' | 'right'
......
export interface AccordionTag {
id?: string | number
label: string
link?: string
}
export interface AccordionGalleryItem {
id?: string | number
image: string
title: string
tags?: AccordionTag[]
link?: string
openInNewTab?: boolean
}
export interface AccordionGalleryProps {
items: AccordionGalleryItem[]
height: number
collapsedFlex: number
expandedFlex: number
gap: number
borderRadius: number
duration: number
maxWidth: number
margin: {
vertical: number
horizontal: number
}
padding: {
vertical: number
horizontal: number
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment