feat: 添加 SpotlightOverlay 组件,更新引导步骤组件以支持高亮功能,调整样式和路由配置
This commit is contained in:
1 parent
aeec7fceed
commit
2b36342304
36 files changed
+863
-29
No files matched your search
@@ -104,11 +104,14 @@ const handleContinue = () => {
|
||||
<style scoped>
|
||||
.guide-dragon-container {
|
||||
@apply w-full h-full relative overflow-hidden;
|
||||
pointer-events: none;
|
||||
z-index: 100002; /* 高于遮罩 */
|
||||
}
|
||||
|
||||
.guide-ui {
|
||||
width: 80%;
|
||||
z-index: 10;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.guide-panel {
|
||||
@@ -189,6 +192,7 @@ const handleContinue = () => {
|
||||
width: 250px;
|
||||
height: 350px;
|
||||
z-index: 5;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dragon-container {
|
||||
|
||||
@@ -1,33 +1,44 @@
|
||||
<template>
|
||||
<div class="guide-steps-container" v-if="show">
|
||||
<GuideDragon
|
||||
:show-skip="currentStep < totalSteps"
|
||||
:current-step="currentStep"
|
||||
:total-steps="totalSteps"
|
||||
@next="handleNext"
|
||||
@skip="handleSkip"
|
||||
@complete="handleComplete"
|
||||
>
|
||||
<template #default>
|
||||
<!-- 支持插槽形式的步骤内容 -->
|
||||
<div v-if="useSlotMode">
|
||||
<component
|
||||
:is="currentStepComponent"
|
||||
@continue="handleNext"
|
||||
:is-last-step="currentStep >= totalSteps"
|
||||
:current-step="currentStep"
|
||||
:total-steps="totalSteps"
|
||||
/>
|
||||
</div>
|
||||
<!-- 兼容原有的配置形式 -->
|
||||
<div v-else v-html="currentStepContent"></div>
|
||||
</template>
|
||||
</GuideDragon>
|
||||
</div>
|
||||
<teleport to="body">
|
||||
<div class="guide-steps-container" v-if="show">
|
||||
<SpotlightOverlay
|
||||
:visible="Boolean(highlightSelector)"
|
||||
:target-selector="highlightSelector"
|
||||
:padding="highlightPadding"
|
||||
:border-radius="highlightRadius"
|
||||
/>
|
||||
<div class="guide-align" :style="alignStyle">
|
||||
<GuideDragon
|
||||
:show-skip="currentStep < totalSteps"
|
||||
:current-step="currentStep"
|
||||
:total-steps="totalSteps"
|
||||
@next="handleNext"
|
||||
@skip="handleSkip"
|
||||
@complete="handleComplete"
|
||||
>
|
||||
<template #default>
|
||||
<!-- 支持插槽形式的步骤内容 -->
|
||||
<div v-if="useSlotMode">
|
||||
<component
|
||||
:is="currentStepComponent"
|
||||
@continue="handleNext"
|
||||
:is-last-step="currentStep >= totalSteps"
|
||||
:current-step="currentStep"
|
||||
:total-steps="totalSteps"
|
||||
/>
|
||||
</div>
|
||||
<!-- 兼容原有的配置形式 -->
|
||||
<div v-else v-html="currentStepContent"></div>
|
||||
</template>
|
||||
</GuideDragon>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, useSlots, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, useSlots, onMounted, onUnmounted, reactive } from 'vue'
|
||||
import SpotlightOverlay from './SpotlightOverlay.vue'
|
||||
import GuideDragon from './GuideDragon.vue'
|
||||
|
||||
interface GuideStep {
|
||||
@@ -46,6 +57,12 @@ interface Props {
|
||||
keyboardNavigation?: boolean
|
||||
/** 自动播放间隔时间(毫秒),0表示不自动播放 */
|
||||
autoPlayInterval?: number
|
||||
/** 当前步骤高亮目标的选择器(CSS选择器),为空则不高亮 */
|
||||
highlightSelector?: string | null
|
||||
/** 高亮内边距,默认8 */
|
||||
highlightPadding?: number
|
||||
/** 高亮圆角,默认16 */
|
||||
highlightRadius?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -58,7 +75,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
steps: () => [],
|
||||
autoStart: true,
|
||||
keyboardNavigation: true,
|
||||
autoPlayInterval: 0
|
||||
autoPlayInterval: 0,
|
||||
highlightSelector: null,
|
||||
highlightPadding: 8,
|
||||
highlightRadius: 16
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
@@ -66,6 +86,22 @@ const emit = defineEmits<Emits>()
|
||||
const slots = useSlots()
|
||||
const show = ref(true)
|
||||
const currentStepIndex = ref(0)
|
||||
const alignStyle = reactive<{
|
||||
left: string
|
||||
top: string
|
||||
transform: string
|
||||
width: string
|
||||
height: string
|
||||
}>({
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
transform: 'scale(1)',
|
||||
width: '1920px',
|
||||
height: '1080px'
|
||||
})
|
||||
const highlightSelector = computed(() => props.highlightSelector || null)
|
||||
const highlightPadding = computed(() => props.highlightPadding || 0)
|
||||
const highlightRadius = computed(() => props.highlightRadius || 0)
|
||||
|
||||
const useSlotMode = computed(() => {
|
||||
return slots.default && slots.default().length > 0
|
||||
@@ -209,11 +245,34 @@ onMounted(() => {
|
||||
if (props.autoStart && props.autoPlayInterval > 0) {
|
||||
startAutoPlay()
|
||||
}
|
||||
// 同步到 .app-content 的缩放与偏移,使引导面板与页面对齐
|
||||
const syncAlign = () => {
|
||||
const el = document.querySelector('.app-content') as HTMLElement | null
|
||||
if (!el) return
|
||||
const style = window.getComputedStyle(el)
|
||||
const transform = style.transform
|
||||
let scale = 1
|
||||
if (transform && transform !== 'none') {
|
||||
const match = transform.match(/matrix\(([^)]+)\)/)
|
||||
if (match) {
|
||||
const parts = match[1].split(',').map(s => parseFloat(s.trim()))
|
||||
if (!Number.isNaN(parts[0])) scale = parts[0]
|
||||
}
|
||||
}
|
||||
alignStyle.transform = `scale(${scale})`
|
||||
alignStyle.left = style.left || '0px'
|
||||
alignStyle.top = style.top || '0px'
|
||||
alignStyle.width = style.width || '1920px'
|
||||
alignStyle.height = style.height || '1080px'
|
||||
}
|
||||
syncAlign()
|
||||
window.addEventListener('resize', syncAlign)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
stopAutoPlay()
|
||||
window.removeEventListener('resize', () => {})
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
@@ -228,6 +287,17 @@ defineExpose({
|
||||
|
||||
<style scoped>
|
||||
.guide-steps-container {
|
||||
@apply w-full h-full relative;
|
||||
@apply w-full h-full;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10001;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.guide-align {
|
||||
position: absolute;
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
z-index: 100002;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div v-if="visible" class="spotlight-overlay">
|
||||
<div
|
||||
class="spotlight-hole"
|
||||
:style="{
|
||||
top: rect.top + 'px',
|
||||
left: rect.left + 'px',
|
||||
width: rect.width + 'px',
|
||||
height: rect.height + 'px',
|
||||
borderRadius: borderRadius + 'px',
|
||||
boxShadow: `0 0 0 9999px rgba(0,0,0,0.6)`
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
interface Props {
|
||||
/** 是否可见 */
|
||||
visible?: boolean
|
||||
/** 目标元素选择器 */
|
||||
targetSelector?: string | null
|
||||
/** 额外内边距 */
|
||||
padding?: number
|
||||
/** 圆角 */
|
||||
borderRadius?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
targetSelector: null,
|
||||
padding: 8,
|
||||
borderRadius: 16
|
||||
})
|
||||
|
||||
const rect = reactive({ top: 0, left: 0, width: 0, height: 0 })
|
||||
|
||||
const measure = () => {
|
||||
if (!props.visible || !props.targetSelector) return
|
||||
const el = document.querySelector(props.targetSelector) as HTMLElement | null
|
||||
if (!el) return
|
||||
const r = el.getBoundingClientRect()
|
||||
const pad = Math.max(0, props.padding)
|
||||
// 使用视口坐标(fixed层),不叠加滚动偏移
|
||||
rect.top = r.top - pad
|
||||
rect.left = r.left - pad
|
||||
rect.width = r.width + pad * 2
|
||||
rect.height = r.height + pad * 2
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.targetSelector, props.padding],
|
||||
() => {
|
||||
requestAnimationFrame(measure)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const onResize = () => measure()
|
||||
const onScroll = () => measure()
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize)
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
measure()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
})
|
||||
|
||||
const borderRadius = props.borderRadius
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.spotlight-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 100000;
|
||||
}
|
||||
|
||||
.spotlight-hole {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
export { default as GuideDragon } from './GuideDragon.vue'
|
||||
export { default as GuideSteps } from './GuideSteps.vue'
|
||||
export { default as GuideStepsItem } from './GuideStepsItem.vue'
|
||||
export { default as SpotlightOverlay } from './SpotlightOverlay.vue'
|
||||
|
||||
// 类型导出
|
||||
export type {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div :class="['img-switch-list', directionClass]">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
:class="['img-switch-item', itemClass, `img-switch-item--${item.key}`]"
|
||||
:data-key="item.key"
|
||||
:style="{ backgroundImage: getBackgroundImage(getItemImage(item)) }"
|
||||
@click="onSelect(item.key)"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { getBackgroundImage } from '@/utils'
|
||||
|
||||
interface SwitchItem {
|
||||
key: string
|
||||
activeImage: string
|
||||
inactiveImage: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: SwitchItem[]
|
||||
modelValue?: string
|
||||
direction?: 'vertical' | 'horizontal'
|
||||
itemClass?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'change', value: string): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
direction: 'vertical',
|
||||
itemClass: ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const directionClass = computed(() => (props.direction === 'vertical' ? 'vertical' : 'horizontal'))
|
||||
|
||||
const getItemImage = (item: SwitchItem) => {
|
||||
return item.key === props.modelValue ? item.activeImage : item.inactiveImage
|
||||
}
|
||||
|
||||
const onSelect = (key: string) => {
|
||||
if (key === props.modelValue) return
|
||||
emit('update:modelValue', key)
|
||||
emit('change', key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.img-switch-list {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.img-switch-list.vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.img-switch-list.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.img-switch-item {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* .img-switch-item:hover {
|
||||
transform: scale(1.05);
|
||||
} */
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ImageSwitchList } from './ImageSwitchList.vue'
|
||||
@@ -10,3 +10,6 @@ export * from './Guide'
|
||||
|
||||
// 进度条组件
|
||||
export * from './ProgressBar'
|
||||
|
||||
// 切换组件
|
||||
export * from './Switcher'
|
||||
Reference in new issue
Block a user