tour-guide-coach 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +301 -0
- package/dist/TourOverlay.vue.d.ts +2 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +397 -0
- package/dist/store.d.ts +21 -0
- package/dist/style.css +1 -0
- package/package.json +48 -0
- package/src/TourOverlay.vue +239 -0
- package/src/configs/demo-form.ts +82 -0
- package/src/index.ts +14 -0
- package/src/store.ts +270 -0
- package/src/types.ts +79 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
漫游式引导 · 全局蒙版组件(零 UI 库依赖)
|
|
3
|
+
-----------------------------------------------------------------
|
|
4
|
+
样式沿用 Element Plus 设计语言(色彩、圆角、字号),但不依赖任何组件库。
|
|
5
|
+
原生 <button> + 内联 SVG + CSS 变量(--tg-* 前缀,自动 fallback --el-*)。
|
|
6
|
+
-->
|
|
7
|
+
<template>
|
|
8
|
+
<Teleport to="body">
|
|
9
|
+
<div v-if="store.active" class="tour-root">
|
|
10
|
+
<!-- 4 块遮罩矩形 -->
|
|
11
|
+
<div class="tour-mask" :style="maskTop" @click="onBlockedClick" />
|
|
12
|
+
<div class="tour-mask" :style="maskBottom" @click="onBlockedClick" />
|
|
13
|
+
<div class="tour-mask" :style="maskLeft" @click="onBlockedClick" />
|
|
14
|
+
<div class="tour-mask" :style="maskRight" @click="onBlockedClick" />
|
|
15
|
+
<!-- 高亮边框 -->
|
|
16
|
+
<div v-if="rect" class="tour-highlight" :style="highlightStyle" />
|
|
17
|
+
<!-- 提示气泡 -->
|
|
18
|
+
<div v-if="step && !store.waitingTarget" class="tour-popover" :style="popoverStyle">
|
|
19
|
+
<div class="tp-head">
|
|
20
|
+
<span class="tp-step-no" :style="{ background: accentColor }">{{ store.stepIndex + 1 }}</span>
|
|
21
|
+
<span v-if="step.gate" class="tp-required">*</span>
|
|
22
|
+
<span class="tp-title">{{ step.title }}</span>
|
|
23
|
+
<span class="tp-of">第 {{ store.stepIndex + 1 }} / {{ store.totalSteps }} 步</span>
|
|
24
|
+
</div>
|
|
25
|
+
<p class="tp-content">{{ step.content }}</p>
|
|
26
|
+
<div v-if="step.tip" class="tp-tip">💡 {{ step.tip }}</div>
|
|
27
|
+
<div v-if="step.warn" class="tp-warn">⚠️ {{ step.warn }}</div>
|
|
28
|
+
<div class="tp-foot">
|
|
29
|
+
<button class="tg-btn tg-btn--link tg-btn--sm" @click="abort()">退出引导</button>
|
|
30
|
+
<!-- trigger=click:只显示提示文字 -->
|
|
31
|
+
<span v-if="step.trigger === 'click'" class="tp-click-hint">
|
|
32
|
+
👆 请点击高亮区域
|
|
33
|
+
</span>
|
|
34
|
+
<!-- 其他步骤:跳过/下一步 -->
|
|
35
|
+
<span v-else-if="store.gatePass" class="tp-btns">
|
|
36
|
+
<button v-if="canSkip" class="tg-btn tg-btn--sm" @click="skip()">跳过此步</button>
|
|
37
|
+
<button class="tg-btn tg-btn--primary tg-btn--sm" :style="{ background: accentColor, borderColor: accentColor }" @click="next()">
|
|
38
|
+
{{ isLast ? '完成' : '下一步' }}
|
|
39
|
+
</button>
|
|
40
|
+
</span>
|
|
41
|
+
<span v-else class="tp-gate">
|
|
42
|
+
<svg class="tp-gate-icon" viewBox="0 0 1024 1024" width="14" height="14" fill="currentColor"><path d="M512 160a96 96 0 0 1 96 96v64H416v-64a96 96 0 0 1 96-96zm0-64a160 160 0 0 0-160 160v64h-32a64 64 0 0 0-64 64v384a64 64 0 0 0 64 64h384a64 64 0 0 0 64-64V384a64 64 0 0 0-64-64h-32v-64A160 160 0 0 0 512 96zM320 448h384v320H320V448z"/></svg>
|
|
43
|
+
{{ step.gateHint || '请先完成本步操作' }}
|
|
44
|
+
</span>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
<!-- 等待目标 -->
|
|
48
|
+
<div v-if="store.waitingTarget" class="tour-waiting">
|
|
49
|
+
<svg v-if="!store.waitTimedOut" class="tg-spin" viewBox="0 0 1024 1024" width="28" height="28" fill="currentColor"><path d="M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896zm0 64a384 384 0 1 0 0 768 384 384 0 0 0 0-768z" opacity=".25"/><path d="M512 64a448 448 0 0 1 448 448h-64A384 384 0 0 0 512 128V64z"/></svg>
|
|
50
|
+
<svg v-else viewBox="0 0 1024 1024" width="28" height="28" fill="#e6a23c"><path d="M512 64L64 896h896L512 64zm0 192l288 576H224l288-576zm-32 224v128h64V480h-64zm0 160v64h64v-64h-64z"/></svg>
|
|
51
|
+
<p>{{ store.waitTimedOut ? '未找到目标元素,可能是页面状态不符' : (step?.content ?? '等待页面加载...') }}</p>
|
|
52
|
+
<button v-if="store.waitTimedOut" class="tg-btn tg-btn--warning tg-btn--sm" @click="next()">跳过此步</button>
|
|
53
|
+
<button class="tg-btn tg-btn--sm" @click="abort()">退出引导</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</Teleport>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<script setup lang="ts">
|
|
60
|
+
import { computed, watch, onBeforeUnmount } from 'vue'
|
|
61
|
+
import { tourState, abort, next, skip, currentStep } from './store'
|
|
62
|
+
import type { CSSProperties } from 'vue'
|
|
63
|
+
|
|
64
|
+
const store = tourState
|
|
65
|
+
|
|
66
|
+
/** ESC 退出引导 */
|
|
67
|
+
function onKeydown(e: KeyboardEvent): void {
|
|
68
|
+
if (e.key === 'Escape' && store.active) abort()
|
|
69
|
+
}
|
|
70
|
+
watch(() => store.active, (isActive) => {
|
|
71
|
+
if (isActive) window.addEventListener('keydown', onKeydown)
|
|
72
|
+
else window.removeEventListener('keydown', onKeydown)
|
|
73
|
+
})
|
|
74
|
+
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
|
75
|
+
|
|
76
|
+
const step = computed(() => currentStep())
|
|
77
|
+
const rect = computed(() => store.highlightRect)
|
|
78
|
+
const accentColor = computed(() => step.value?.accent ?? store.config?.accent ?? '#409eff')
|
|
79
|
+
const isLast = computed(() => store.stepIndex >= store.totalSteps - 1)
|
|
80
|
+
const canSkip = computed(() => {
|
|
81
|
+
const s = step.value
|
|
82
|
+
if (!s) return false
|
|
83
|
+
return !s.gate && s.trigger === 'manual'
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// ---------- 蒙版 4 块定位 ----------
|
|
87
|
+
function calcMasks() {
|
|
88
|
+
const r = rect.value
|
|
89
|
+
if (!r) return null
|
|
90
|
+
return {
|
|
91
|
+
top: { left: 0, top: 0, width: '100vw', height: `${Math.max(r.top, 0)}px` },
|
|
92
|
+
bottom: { left: 0, top: `${r.top + r.height}px`, width: '100vw', height: `calc(100vh - ${r.top + r.height}px)` },
|
|
93
|
+
left: { left: 0, top: `${r.top}px`, width: `${Math.max(r.left, 0)}px`, height: `${r.height}px` },
|
|
94
|
+
right: { left: `${r.left + r.width}px`, top: `${r.top}px`, width: `calc(100vw - ${r.left + r.width}px)`, height: `${r.height}px` }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const masks = computed(calcMasks)
|
|
98
|
+
const maskTop = computed<CSSProperties>(() => masks.value?.top ?? { display: 'none' })
|
|
99
|
+
const maskBottom = computed<CSSProperties>(() => masks.value?.bottom ?? { display: 'none' })
|
|
100
|
+
const maskLeft = computed<CSSProperties>(() => masks.value?.left ?? { display: 'none' })
|
|
101
|
+
const maskRight = computed<CSSProperties>(() => masks.value?.right ?? { display: 'none' })
|
|
102
|
+
|
|
103
|
+
// ---------- 高亮框 ----------
|
|
104
|
+
const highlightStyle = computed<CSSProperties>(() => {
|
|
105
|
+
const r = rect.value
|
|
106
|
+
if (!r) return { display: 'none' }
|
|
107
|
+
return {
|
|
108
|
+
position: 'fixed', top: `${r.top}px`, left: `${r.left}px`,
|
|
109
|
+
width: `${r.width}px`, height: `${r.height}px`,
|
|
110
|
+
borderRadius: '8px', border: `2px solid ${accentColor.value}`,
|
|
111
|
+
boxShadow: `0 0 0 3px ${accentColor.value}33, 0 0 20px ${accentColor.value}44`,
|
|
112
|
+
pointerEvents: 'none', transition: 'all 0.35s cubic-bezier(0.4,0,0.2,1)'
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// ---------- Popover 定位 ----------
|
|
117
|
+
const popoverStyle = computed<CSSProperties>(() => {
|
|
118
|
+
const r = rect.value
|
|
119
|
+
if (!r) return { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%,-50%)' }
|
|
120
|
+
const placement = step.value?.placement ?? 'bottom'
|
|
121
|
+
const gap = 14
|
|
122
|
+
const base: CSSProperties = { position: 'fixed', zIndex: 100002 }
|
|
123
|
+
switch (placement) {
|
|
124
|
+
case 'top': return { ...base, left: `${r.left}px`, bottom: `calc(100vh - ${r.top - gap}px)`, maxWidth: '420px' }
|
|
125
|
+
case 'bottom': return { ...base, left: `${r.left}px`, top: `${r.top + r.height + gap}px`, maxWidth: '420px' }
|
|
126
|
+
case 'left': return { ...base, right: `calc(100vw - ${r.left - gap}px)`, top: `${r.top}px`, maxWidth: '360px' }
|
|
127
|
+
case 'right': return { ...base, left: `${r.left + r.width + gap}px`, top: `${r.top}px`, maxWidth: '360px' }
|
|
128
|
+
default: return { ...base, top: '50%', left: '50%', transform: 'translate(-50%,-50%)', maxWidth: '480px' }
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
function onBlockedClick(): void { /* 静默拦截 */ }
|
|
133
|
+
</script>
|
|
134
|
+
|
|
135
|
+
<style scoped>
|
|
136
|
+
/* ---------- CSS 变量:自动 fallback 到 --el-*(兼容 EP 项目) ---------- */
|
|
137
|
+
.tour-root {
|
|
138
|
+
--tg-primary: var(--el-color-primary, #409eff);
|
|
139
|
+
--tg-success: var(--el-color-success, #67c23a);
|
|
140
|
+
--tg-warning: var(--el-color-warning, #e6a23c);
|
|
141
|
+
--tg-danger: var(--el-color-danger, #f56c6c);
|
|
142
|
+
--tg-text-1: var(--el-text-color-primary, #303133);
|
|
143
|
+
--tg-text-2: var(--el-text-color-regular, #606266);
|
|
144
|
+
--tg-text-3: var(--el-text-color-secondary, #909399);
|
|
145
|
+
--tg-border: var(--el-border-color-lighter, #ebeef5);
|
|
146
|
+
--tg-fill-light: var(--el-fill-color-light, #f5f7fa);
|
|
147
|
+
--tg-radius: var(--el-border-radius-base, 4px);
|
|
148
|
+
|
|
149
|
+
position: fixed; inset: 0; z-index: 100000;
|
|
150
|
+
pointer-events: none;
|
|
151
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* ---------- 遮罩 ---------- */
|
|
155
|
+
.tour-mask {
|
|
156
|
+
position: fixed; background: rgba(0,0,0,0.55);
|
|
157
|
+
transition: all 0.35s cubic-bezier(0.4,0,0.2,1);
|
|
158
|
+
cursor: not-allowed; pointer-events: auto;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* ---------- 高亮框 ---------- */
|
|
162
|
+
.tour-highlight { z-index: 100001; }
|
|
163
|
+
|
|
164
|
+
/* ---------- 按钮(EP 风格) ---------- */
|
|
165
|
+
.tg-btn {
|
|
166
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
167
|
+
border: 1px solid var(--tg-border); border-radius: var(--tg-radius);
|
|
168
|
+
background: #fff; color: var(--tg-text-2);
|
|
169
|
+
font-size: 14px; font-weight: 500; line-height: 1;
|
|
170
|
+
padding: 8px 15px; cursor: pointer;
|
|
171
|
+
transition: color .2s, border-color .2s, background .2s;
|
|
172
|
+
outline: none; white-space: nowrap; user-select: none;
|
|
173
|
+
}
|
|
174
|
+
.tg-btn:hover { color: var(--tg-primary); border-color: var(--tg-primary); background: #ecf5ff; }
|
|
175
|
+
.tg-btn--sm { padding: 5px 11px; font-size: 12px; }
|
|
176
|
+
.tg-btn--primary {
|
|
177
|
+
background: var(--tg-primary); border-color: var(--tg-primary); color: #fff;
|
|
178
|
+
}
|
|
179
|
+
.tg-btn--primary:hover { opacity: .85; background: var(--tg-primary); color: #fff; }
|
|
180
|
+
.tg-btn--warning {
|
|
181
|
+
background: var(--tg-warning); border-color: var(--tg-warning); color: #fff;
|
|
182
|
+
}
|
|
183
|
+
.tg-btn--warning:hover { opacity: .85; background: var(--tg-warning); color: #fff; }
|
|
184
|
+
.tg-btn--link {
|
|
185
|
+
border: none; background: none; padding: 4px 6px;
|
|
186
|
+
color: var(--tg-text-3);
|
|
187
|
+
}
|
|
188
|
+
.tg-btn--link:hover { color: var(--tg-primary); background: none; }
|
|
189
|
+
|
|
190
|
+
/* ---------- 气泡 ---------- */
|
|
191
|
+
.tour-popover {
|
|
192
|
+
z-index: 100002; background: #fff; border-radius: 12px;
|
|
193
|
+
padding: 20px 24px; box-shadow: 0 8px 40px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06);
|
|
194
|
+
animation: tp-in 0.3s ease; pointer-events: auto;
|
|
195
|
+
min-width: 280px; max-width: 420px;
|
|
196
|
+
}
|
|
197
|
+
@keyframes tp-in {
|
|
198
|
+
from { opacity: 0; transform: translateY(6px) scale(0.97); }
|
|
199
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
200
|
+
}
|
|
201
|
+
.tp-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
|
202
|
+
.tp-step-no {
|
|
203
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
204
|
+
width: 28px; height: 28px; border-radius: 50%;
|
|
205
|
+
color: #fff; font-size: 14px; font-weight: 700; flex-shrink: 0;
|
|
206
|
+
}
|
|
207
|
+
.tp-required { color: var(--tg-danger); font-size: 20px; font-weight: 700; line-height: 1; }
|
|
208
|
+
.tp-title { font-size: 16px; font-weight: 700; color: var(--tg-text-1); }
|
|
209
|
+
.tp-of { font-size: 13px; color: var(--tg-text-3); margin-left: auto; font-variant-numeric: tabular-nums; }
|
|
210
|
+
.tp-content { margin: 0 0 14px; font-size: 14px; line-height: 1.8; color: var(--tg-text-2); white-space: pre-line; }
|
|
211
|
+
.tp-tip {
|
|
212
|
+
font-size: 13px; color: #529b2e; background: #f0f9eb;
|
|
213
|
+
padding: 8px 12px; border-radius: 6px; margin-bottom: 10px;
|
|
214
|
+
border-left: 3px solid #67c23a;
|
|
215
|
+
}
|
|
216
|
+
.tp-warn {
|
|
217
|
+
font-size: 13px; color: #b88230; background: #fdf6ec;
|
|
218
|
+
padding: 8px 12px; border-radius: 6px; margin-bottom: 10px;
|
|
219
|
+
border-left: 3px solid #e6a23c;
|
|
220
|
+
}
|
|
221
|
+
.tp-foot { display: flex; align-items: center; justify-content: space-between; padding-top: 14px; border-top: 1px solid var(--tg-border); }
|
|
222
|
+
.tp-btns { display: flex; gap: 10px; }
|
|
223
|
+
.tp-gate { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; color: var(--tg-warning); font-weight: 500; }
|
|
224
|
+
.tp-gate-icon { flex-shrink: 0; }
|
|
225
|
+
.tp-click-hint { font-size: 14px; color: var(--tg-primary); font-weight: 500; animation: pulse-hint 1.5s ease-in-out infinite; }
|
|
226
|
+
@keyframes pulse-hint { 0%,100%{opacity:1} 50%{opacity:.5} }
|
|
227
|
+
|
|
228
|
+
/* ---------- 等待态 ---------- */
|
|
229
|
+
.tour-waiting {
|
|
230
|
+
position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%);
|
|
231
|
+
z-index: 100002; background: #fff; padding: 32px 40px;
|
|
232
|
+
border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
|
233
|
+
text-align: center; pointer-events: auto;
|
|
234
|
+
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
|
235
|
+
}
|
|
236
|
+
.tour-waiting p { margin: 4px 0 8px; font-size: 14px; color: var(--tg-text-2); }
|
|
237
|
+
.tg-spin { animation: tg-rotate 1s linear infinite; }
|
|
238
|
+
@keyframes tg-rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
239
|
+
</style>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 示例引导配置 · 表单创建流程
|
|
3
|
+
* -----------------------------------------------------------------
|
|
4
|
+
* 演示 trigger 类型、gate 门控、expandSelector 动态高亮的完整用法。
|
|
5
|
+
* 对应 demo 页面的表单弹窗。
|
|
6
|
+
*/
|
|
7
|
+
import type { TourConfig } from '../types'
|
|
8
|
+
|
|
9
|
+
export const demoFormTour: TourConfig = {
|
|
10
|
+
key: 'demo-form',
|
|
11
|
+
name: '新建表单引导',
|
|
12
|
+
accent: '#409eff',
|
|
13
|
+
steps: [
|
|
14
|
+
{
|
|
15
|
+
id: 'open-btn',
|
|
16
|
+
target: '#demo-create-btn',
|
|
17
|
+
placement: 'bottom',
|
|
18
|
+
title: '点击「新建」',
|
|
19
|
+
content: '点击这个按钮打开表单弹窗。',
|
|
20
|
+
trigger: 'click',
|
|
21
|
+
afterClickWait: '.el-dialog__body .el-form-item:nth-child(1)',
|
|
22
|
+
clickDelay: 500
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'name-field',
|
|
26
|
+
target: '.el-dialog__body .el-form-item:nth-child(1) .el-input',
|
|
27
|
+
placement: 'right',
|
|
28
|
+
title: '填写名称',
|
|
29
|
+
content: '输入名称(必填)。',
|
|
30
|
+
trigger: 'input',
|
|
31
|
+
gate: (resolve) => {
|
|
32
|
+
const el = resolve('.el-dialog__body .el-form-item:nth-child(1) input')
|
|
33
|
+
return !!(el as HTMLInputElement | null)?.value?.trim()
|
|
34
|
+
},
|
|
35
|
+
gateHint: '请先输入名称',
|
|
36
|
+
tip: '名称是唯一必填字段。'
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'category-select',
|
|
40
|
+
target: '.el-dialog__body .el-form-item:nth-child(2) .el-select',
|
|
41
|
+
expandSelector: '.el-select-dropdown',
|
|
42
|
+
placement: 'right',
|
|
43
|
+
title: '选择分类',
|
|
44
|
+
content: '从下拉中选择分类(必填)。\n注意下拉面板也被高亮区域包含。',
|
|
45
|
+
trigger: 'change',
|
|
46
|
+
gate: (resolve) => {
|
|
47
|
+
const el = resolve('.el-dialog__body .el-form-item:nth-child(2) .el-select')
|
|
48
|
+
if (!el) return false
|
|
49
|
+
const ph = el.querySelector('.el-select__placeholder')
|
|
50
|
+
if (!ph) return true
|
|
51
|
+
return !ph.classList.contains('is-transparent')
|
|
52
|
+
},
|
|
53
|
+
gateHint: '请选择一个分类'
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'date-field',
|
|
57
|
+
target: '.el-dialog__body .el-form-item:nth-child(3) .el-input',
|
|
58
|
+
expandSelector: '.el-picker-panel',
|
|
59
|
+
placement: 'right',
|
|
60
|
+
title: '选择日期',
|
|
61
|
+
content: '选择日期(选填)。\n日历面板同样使用动态高亮扩展。',
|
|
62
|
+
trigger: 'manual'
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'remark-field',
|
|
66
|
+
target: '.el-dialog__body .el-form-item:nth-child(4) .el-textarea',
|
|
67
|
+
placement: 'right',
|
|
68
|
+
title: '备注',
|
|
69
|
+
content: '填写备注(选填),可以直接点「下一步」跳过。',
|
|
70
|
+
trigger: 'manual'
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'submit-btn',
|
|
74
|
+
target: '.el-dialog__footer .el-button--primary',
|
|
75
|
+
placement: 'top',
|
|
76
|
+
title: '提交表单',
|
|
77
|
+
content: '点击「确定」提交,引导结束。',
|
|
78
|
+
trigger: 'click',
|
|
79
|
+
clickDelay: 500
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vue-tour-guide · 公共入口
|
|
3
|
+
* -----------------------------------------------------------------
|
|
4
|
+
* 零外部依赖(仅需 Vue 3),全局单例模式。
|
|
5
|
+
*
|
|
6
|
+
* 使用方式:
|
|
7
|
+
* import { tourState, startTour, abort, TourOverlay } from 'vue-tour-guide'
|
|
8
|
+
* // 在 layout 中挂 <TourOverlay />
|
|
9
|
+
* // 业务页调用 startTour(config)
|
|
10
|
+
*/
|
|
11
|
+
export { tourState, startTour, abort, next, skip, complete } from './store'
|
|
12
|
+
export type { TargetResolver } from './store'
|
|
13
|
+
export { default as TourOverlay } from './TourOverlay.vue'
|
|
14
|
+
export type { TourConfig, TourStep, StepTrigger, PopPlacement, HighlightRect } from './types'
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 漫游式引导引擎 · 状态机(模块级单例,零外部依赖)
|
|
3
|
+
* -----------------------------------------------------------------
|
|
4
|
+
* 用 Vue 3 的 reactive + ref 替代 Pinia。
|
|
5
|
+
* 全局单例——同一时间只允许一个 tour 播放。
|
|
6
|
+
*
|
|
7
|
+
* 生命周期:
|
|
8
|
+
* startTour(config, resolver) → 逐步 enterStep → resolveTarget
|
|
9
|
+
* → 绑定 trigger 监听 → 用户操作满足条件 → next() → complete/abort
|
|
10
|
+
*/
|
|
11
|
+
import { ref, shallowReactive } from 'vue'
|
|
12
|
+
import type { TourConfig, TourStep, HighlightRect } from './types'
|
|
13
|
+
|
|
14
|
+
/** 元素解析器:宿主页面提供,把 target 名解析为 DOM 元素 */
|
|
15
|
+
export type TargetResolver = (target: string) => HTMLElement | null
|
|
16
|
+
|
|
17
|
+
const GATE_POLL_INTERVAL = 150
|
|
18
|
+
const ELEMENT_WAIT_TIMEOUT = 12000
|
|
19
|
+
|
|
20
|
+
// ---------- 响应式状态(模块级单例) ----------
|
|
21
|
+
export const tourState = shallowReactive({
|
|
22
|
+
active: false,
|
|
23
|
+
config: null as TourConfig | null,
|
|
24
|
+
stepIndex: 0,
|
|
25
|
+
totalSteps: 0,
|
|
26
|
+
highlightRect: null as HighlightRect | null,
|
|
27
|
+
targetEl: null as HTMLElement | null,
|
|
28
|
+
gatePass: true,
|
|
29
|
+
waitingTarget: false,
|
|
30
|
+
waitTimedOut: false
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// ---------- 内部状态(非响应式) ----------
|
|
34
|
+
let onComplete: (() => void) | null = null
|
|
35
|
+
let _resolver: TargetResolver | null = null
|
|
36
|
+
let _gateTimer: ReturnType<typeof setInterval> | null = null
|
|
37
|
+
let _waitObserver: MutationObserver | null = null
|
|
38
|
+
let _waitTimer: ReturnType<typeof setTimeout> | null = null
|
|
39
|
+
let _clickDelayTimer: ReturnType<typeof setTimeout> | null = null
|
|
40
|
+
let _resizeObs: ResizeObserver | null = null
|
|
41
|
+
let _scrollHandler: (() => void) | null = null
|
|
42
|
+
let _expandObs: MutationObserver | null = null
|
|
43
|
+
let _currentStep: TourStep | null = null
|
|
44
|
+
|
|
45
|
+
function _currentStepFn(): TourStep | null {
|
|
46
|
+
return tourState.config?.steps[tourState.stepIndex] ?? null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 获取当前步骤(供组件使用) */
|
|
50
|
+
export function currentStep(): TourStep | null {
|
|
51
|
+
return _currentStepFn()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------- 启动引导 ----------
|
|
55
|
+
export function startTour(tour: TourConfig, resolver?: TargetResolver, done?: () => void): void {
|
|
56
|
+
if (tourState.active) abort()
|
|
57
|
+
tourState.config = tour
|
|
58
|
+
tourState.stepIndex = 0
|
|
59
|
+
tourState.totalSteps = tour.steps.length
|
|
60
|
+
tourState.gatePass = true
|
|
61
|
+
onComplete = done ?? null
|
|
62
|
+
_resolver = resolver ?? null
|
|
63
|
+
tourState.active = true
|
|
64
|
+
_enterStep()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function abort(): void {
|
|
68
|
+
_cleanup()
|
|
69
|
+
tourState.active = false
|
|
70
|
+
tourState.config = null
|
|
71
|
+
tourState.highlightRect = null
|
|
72
|
+
tourState.targetEl = null
|
|
73
|
+
_resolver = null
|
|
74
|
+
onComplete = null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function complete(): void {
|
|
78
|
+
_cleanup()
|
|
79
|
+
tourState.active = false
|
|
80
|
+
tourState.highlightRect = null
|
|
81
|
+
tourState.targetEl = null
|
|
82
|
+
const cb = onComplete
|
|
83
|
+
_resolver = null
|
|
84
|
+
onComplete = null
|
|
85
|
+
cb?.()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------- 步骤推进 ----------
|
|
89
|
+
export function next(): void {
|
|
90
|
+
_cleanup()
|
|
91
|
+
const cfg = tourState.config
|
|
92
|
+
if (!cfg) return
|
|
93
|
+
if (tourState.stepIndex >= cfg.steps.length - 1) { complete(); return }
|
|
94
|
+
tourState.stepIndex += 1
|
|
95
|
+
_enterStep()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function skip(): void { next() }
|
|
99
|
+
|
|
100
|
+
// ---------- 进入当前步 ----------
|
|
101
|
+
function _enterStep(): void {
|
|
102
|
+
const step = _currentStepFn()
|
|
103
|
+
if (!step) { complete(); return }
|
|
104
|
+
_currentStep = step
|
|
105
|
+
tourState.gatePass = step.gate ? false : true
|
|
106
|
+
tourState.waitingTarget = true
|
|
107
|
+
tourState.waitTimedOut = false
|
|
108
|
+
tourState.highlightRect = null
|
|
109
|
+
tourState.targetEl = null
|
|
110
|
+
|
|
111
|
+
const enterResult = step.beforeEnter?.()
|
|
112
|
+
if (enterResult instanceof Promise) {
|
|
113
|
+
enterResult.then(() => _resolveTarget(step))
|
|
114
|
+
} else {
|
|
115
|
+
requestAnimationFrame(() => _resolveTarget(step))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------- 解析目标元素 ----------
|
|
120
|
+
function _resolveTarget(step: TourStep): void {
|
|
121
|
+
if (step.trigger === 'auto') {
|
|
122
|
+
tourState.waitingTarget = false
|
|
123
|
+
_autoAdvance(step)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (step.trigger === 'wait-dom') {
|
|
127
|
+
_waitForDom(step)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
const el = _findTarget(step.target)
|
|
131
|
+
if (el) { _onTargetReady(el, step); return }
|
|
132
|
+
_startWaitForElement(step.target, () => {
|
|
133
|
+
const el2 = _findTarget(step.target)
|
|
134
|
+
_onTargetReady(el2, step)
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function _findTarget(target: string): HTMLElement | null {
|
|
139
|
+
if (_resolver) {
|
|
140
|
+
const el = _resolver(target)
|
|
141
|
+
if (el) return el
|
|
142
|
+
}
|
|
143
|
+
try { return document.querySelector(target) } catch { return null }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _onTargetReady(el: HTMLElement | null, step: TourStep): void {
|
|
147
|
+
if (!el) { tourState.waitTimedOut = true; return }
|
|
148
|
+
tourState.waitingTarget = false
|
|
149
|
+
tourState.waitTimedOut = false
|
|
150
|
+
tourState.targetEl = el
|
|
151
|
+
_updateRect(el)
|
|
152
|
+
_bindTrigger(el, step)
|
|
153
|
+
_startGate(step)
|
|
154
|
+
_startTrackingRect(el)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------- 高亮矩形跟踪 ----------
|
|
158
|
+
function _updateRect(el: HTMLElement): void {
|
|
159
|
+
const r = el.getBoundingClientRect()
|
|
160
|
+
const pad = 6
|
|
161
|
+
let rect: HighlightRect = {
|
|
162
|
+
top: r.top - pad, left: r.left - pad,
|
|
163
|
+
width: r.width + pad * 2, height: r.height + pad * 2
|
|
164
|
+
}
|
|
165
|
+
const expandSel = _currentStep?.expandSelector
|
|
166
|
+
if (expandSel) {
|
|
167
|
+
const candidates = document.querySelectorAll(expandSel)
|
|
168
|
+
let expandEl: HTMLElement | null = null
|
|
169
|
+
for (const c of candidates) {
|
|
170
|
+
if ((c as HTMLElement).offsetHeight > 0) { expandEl = c as HTMLElement; break }
|
|
171
|
+
}
|
|
172
|
+
if (expandEl) {
|
|
173
|
+
const er = expandEl.getBoundingClientRect()
|
|
174
|
+
const top = Math.min(rect.top, er.top - pad)
|
|
175
|
+
const left = Math.min(rect.left, er.left - pad)
|
|
176
|
+
const right = Math.max(rect.left + rect.width, er.right + pad)
|
|
177
|
+
const bottom = Math.max(rect.top + rect.height, er.bottom + pad)
|
|
178
|
+
rect = { top, left, width: right - left, height: bottom - top }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
tourState.highlightRect = rect
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _startTrackingRect(el: HTMLElement): void {
|
|
185
|
+
_stopTrackingRect()
|
|
186
|
+
_scrollHandler = () => _updateRect(el)
|
|
187
|
+
window.addEventListener('scroll', _scrollHandler, true)
|
|
188
|
+
window.addEventListener('resize', _scrollHandler)
|
|
189
|
+
_resizeObs = new ResizeObserver(() => _updateRect(el))
|
|
190
|
+
_resizeObs.observe(el)
|
|
191
|
+
const expandSel = _currentStep?.expandSelector
|
|
192
|
+
if (expandSel) {
|
|
193
|
+
_expandObs = new MutationObserver(() => _updateRect(el))
|
|
194
|
+
_expandObs.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] })
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function _stopTrackingRect(): void {
|
|
199
|
+
if (_scrollHandler) {
|
|
200
|
+
window.removeEventListener('scroll', _scrollHandler, true)
|
|
201
|
+
window.removeEventListener('resize', _scrollHandler)
|
|
202
|
+
}
|
|
203
|
+
_scrollHandler = null
|
|
204
|
+
_resizeObs?.disconnect(); _resizeObs = null
|
|
205
|
+
_expandObs?.disconnect(); _expandObs = null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------- 事件绑定 ----------
|
|
209
|
+
function _bindTrigger(el: HTMLElement, step: TourStep): void {
|
|
210
|
+
if (step.trigger === 'click') {
|
|
211
|
+
const handler = () => {
|
|
212
|
+
el.removeEventListener('click', handler)
|
|
213
|
+
const delay = step.clickDelay ?? 350
|
|
214
|
+
if (step.afterClickWait) {
|
|
215
|
+
_waitForDom({ waitForElement: step.afterClickWait, clickDelay: delay } as TourStep)
|
|
216
|
+
} else {
|
|
217
|
+
_clickDelayTimer = setTimeout(() => next(), delay)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
el.addEventListener('click', handler)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------- Gate 轮询 ----------
|
|
225
|
+
function _startGate(step: TourStep): void {
|
|
226
|
+
if (!step.gate) { tourState.gatePass = true; return }
|
|
227
|
+
const resolve = (t: string) => _findTarget(t)
|
|
228
|
+
tourState.gatePass = step.gate(resolve)
|
|
229
|
+
if (tourState.gatePass) return
|
|
230
|
+
_gateTimer = setInterval(() => {
|
|
231
|
+
if (step.gate!(resolve)) {
|
|
232
|
+
tourState.gatePass = true
|
|
233
|
+
if (_gateTimer) { clearInterval(_gateTimer); _gateTimer = null }
|
|
234
|
+
}
|
|
235
|
+
}, GATE_POLL_INTERVAL)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------- 等待 DOM ----------
|
|
239
|
+
function _waitForDom(step: TourStep): void {
|
|
240
|
+
const sel = step.waitForElement
|
|
241
|
+
if (!sel) { next(); return }
|
|
242
|
+
if (_findTarget(sel)) {
|
|
243
|
+
_clickDelayTimer = setTimeout(() => next(), step.clickDelay ?? 300)
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
_startWaitForElement(sel, () => {
|
|
247
|
+
_clickDelayTimer = setTimeout(() => next(), step.clickDelay ?? 300)
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function _startWaitForElement(target: string, cb: () => void): void {
|
|
252
|
+
let resolved = false
|
|
253
|
+
const finish = () => { if (!resolved) { resolved = true; _waitObserver?.disconnect(); _waitObserver = null; cb() } }
|
|
254
|
+
_waitObserver = new MutationObserver(() => { if (_findTarget(target)) finish() })
|
|
255
|
+
_waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
256
|
+
_waitTimer = setTimeout(finish, ELEMENT_WAIT_TIMEOUT)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function _autoAdvance(step: TourStep): void {
|
|
260
|
+
_clickDelayTimer = setTimeout(() => next(), step.autoDelay ?? 600)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------- 清理 ----------
|
|
264
|
+
function _cleanup(): void {
|
|
265
|
+
_stopTrackingRect()
|
|
266
|
+
if (_gateTimer) { clearInterval(_gateTimer); _gateTimer = null }
|
|
267
|
+
if (_waitObserver) { _waitObserver.disconnect(); _waitObserver = null }
|
|
268
|
+
if (_waitTimer) { clearTimeout(_waitTimer); _waitTimer = null }
|
|
269
|
+
if (_clickDelayTimer) { clearTimeout(_clickDelayTimer); _clickDelayTimer = null }
|
|
270
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 漫游式引导引擎 · 类型定义
|
|
3
|
+
* -----------------------------------------------------------------
|
|
4
|
+
* 交互式引导:蒙版 + 元素高亮 + 事件驱动推步(用户真实操作才推进)。
|
|
5
|
+
*
|
|
6
|
+
* StepTrigger 语义:
|
|
7
|
+
* click — 用户点击高亮元素后自动前进
|
|
8
|
+
* input — 高亮的输入框值满足 condition 后前进(如非空)
|
|
9
|
+
* change — 高亮元素的值变化后前进(select / tree-select)
|
|
10
|
+
* wait-dom — 等待 waitForElement 选择器匹配到 DOM 节点(弹窗出现、路由切换)
|
|
11
|
+
* auto — 自动前进(延时后,用于页面跳转/加载过渡)
|
|
12
|
+
* manual — 显示"下一步"按钮,用户手动推进(信息展示步骤)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 弹窗提示框相对高亮区域的弹出方位 */
|
|
16
|
+
export type PopPlacement = 'top' | 'bottom' | 'left' | 'right' | 'center'
|
|
17
|
+
|
|
18
|
+
/** 引导步骤 */
|
|
19
|
+
export interface TourStep {
|
|
20
|
+
/** 步骤唯一 ID(config 内不重复) */
|
|
21
|
+
id: string
|
|
22
|
+
/** 高亮目标 CSS 选择器(`[data-tour="xxx"]`、`#tab-tasks` 等) */
|
|
23
|
+
target: string
|
|
24
|
+
/** 动态扩展选择器:当该选择器匹配的元素可见时,高亮区域扩展为 target + expand 联合矩形。
|
|
25
|
+
* 典型场景:el-select 展开后 popper teleport 到 body,需要把下拉面板纳入可交互区域。 */
|
|
26
|
+
expandSelector?: string
|
|
27
|
+
/** 弹窗提示框弹出方位 */
|
|
28
|
+
placement?: PopPlacement
|
|
29
|
+
/** 步骤标题 */
|
|
30
|
+
title: string
|
|
31
|
+
/** 步骤正文(支持 \n 换行) */
|
|
32
|
+
content: string
|
|
33
|
+
/** 推进触发方式 */
|
|
34
|
+
trigger: StepTrigger
|
|
35
|
+
/** 推进门控条件(trigger=input/change 时,引擎轮询检查;返回 true 才解锁"下一步")。
|
|
36
|
+
* 参数 resolve 是宿主提供的元素解析器,gate 内可调用 resolve('nameRef:input') 取 DOM */
|
|
37
|
+
gate?: (resolve: (target: string) => HTMLElement | null) => boolean
|
|
38
|
+
/** 门控未通过时"下一步"按钮下方提示文案 */
|
|
39
|
+
gateHint?: string
|
|
40
|
+
/** trigger=wait-dom 时,等待该选择器出现在 DOM */
|
|
41
|
+
waitForElement?: string
|
|
42
|
+
/** trigger=auto 时,自动前进延时(ms),默认 600 */
|
|
43
|
+
autoDelay?: number
|
|
44
|
+
/** trigger=click 时,前进延时(ms),等 click 引起的 DOM 变化完成,默认 350 */
|
|
45
|
+
clickDelay?: number
|
|
46
|
+
/** 额外等待:click 后等该选择器出现才推下一步(如"点了新建 → 等弹窗") */
|
|
47
|
+
afterClickWait?: string
|
|
48
|
+
/** 小贴士(可选,绿色块) */
|
|
49
|
+
tip?: string
|
|
50
|
+
/** 注意事项(可选,橙色块) */
|
|
51
|
+
warn?: string
|
|
52
|
+
/** 本步的强调色覆盖(默认用 tour 全局色) */
|
|
53
|
+
accent?: string
|
|
54
|
+
/** 是否在进入本步前执行导航(如 router.push) */
|
|
55
|
+
beforeEnter?: () => void | Promise<void>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 步骤触发方式 */
|
|
59
|
+
export type StepTrigger = 'click' | 'input' | 'change' | 'wait-dom' | 'auto' | 'manual'
|
|
60
|
+
|
|
61
|
+
/** 一个完整引导 */
|
|
62
|
+
export interface TourConfig {
|
|
63
|
+
/** 引导唯一 key */
|
|
64
|
+
key: string
|
|
65
|
+
/** 引导名(如「新建任务引导」) */
|
|
66
|
+
name: string
|
|
67
|
+
/** 全局强调色 */
|
|
68
|
+
accent: string
|
|
69
|
+
/** 引导步骤列表(按序播放) */
|
|
70
|
+
steps: TourStep[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 高亮矩形(viewport 坐标系,引擎每帧更新) */
|
|
74
|
+
export interface HighlightRect {
|
|
75
|
+
top: number
|
|
76
|
+
left: number
|
|
77
|
+
width: number
|
|
78
|
+
height: number
|
|
79
|
+
}
|