Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
B
boyueCEnd
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
游洁
boyueCEnd
Commits
e159daa8
Commit
e159daa8
authored
Dec 04, 2025
by
罗超
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
新增手风琴卡片
parent
af3e1ed6
Hide whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
393 additions
and
13 deletions
+393
-13
AccordionGallery.vue
src/components/page-builder/AccordionGallery.vue
+254
-0
Carousel.vue
src/components/page-builder/Carousel.vue
+92
-11
ComponentRenderer.vue
src/components/page-builder/ComponentRenderer.vue
+4
-1
HomeLayout.vue
src/layouts/HomeLayout.vue
+1
-1
OTAPageRenderer.vue
src/renderer/OTAPageRenderer.vue
+1
-0
carousel.ts
src/types/carousel.ts
+6
-0
accordionGallery.ts
src/types/page-builder/accordionGallery.ts
+35
-0
No files found.
src/components/page-builder/AccordionGallery.vue
0 → 100644
View file @
e159daa8
<
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
>
src/components/page-builder/Carousel.vue
View file @
e159daa8
...
@@ -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
,
on
Mounted
,
on
Unmounted
}
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'
);
...
...
src/components/page-builder/ComponentRenderer.vue
View file @
e159daa8
...
@@ -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
<
{
...
...
src/layouts/HomeLayout.vue
View file @
e159daa8
<
template
>
<
template
>
<div
class=
"h-screen overflow-y-auto"
>
<div
class=
"h-screen overflow-y-auto"
id=
"app-document"
>
<!-- 顶部工具栏 -->
<!-- 顶部工具栏 -->
<Headers
/>
<Headers
/>
<!-- 页面内容 -->
<!-- 页面内容 -->
...
...
src/renderer/OTAPageRenderer.vue
View file @
e159daa8
...
@@ -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'
)),
...
...
src/types/carousel.ts
View file @
e159daa8
...
@@ -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'
...
...
src/types/page-builder/accordionGallery.ts
0 → 100644
View file @
e159daa8
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
}
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment